diff --git a/.travis.yml b/.travis.yml index d94b108..8ca00f3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,4 +20,7 @@ install: - travis_retry composer update --prefer-dist --no-interaction --prefer-stable --no-suggest script: - - composer test + - composer test -- --coverage-clover=coverage.xml + +after_success: + - bash <(curl -s https://codecov.io/bash) diff --git a/README.md b/README.md index 681afdb..222b303 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Apitizer [![Build Status](https://travis-ci.org/drtheuns/apitizer_php.svg?branch=master)](https://travis-ci.org/drtheuns/apitizer_php) +[![codecov](https://codecov.io/gh/drtheuns/apitizer_php/branch/master/graph/badge.svg)](https://codecov.io/gh/drtheuns/apitizer_php) Apitizer is a Laravel library that primarily offers a Query Builder that allows you to easily create documented API endpoints that are capable of filtering, diff --git a/composer.json b/composer.json index 248f95e..84bb905 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ "require-dev": { "phpunit/phpunit": "^8.5", "orchestra/testbench": "^4.0", - "mockery/mockery": "^1.3" + "mockery/mockery": "^1.3", + "nunomaduro/larastan": "^0.5.2" }, "autoload": { "psr-4": { @@ -38,6 +39,7 @@ }, "scripts": { "test": "./vendor/bin/phpunit", - "coverage": "./vendor/bin/phpunit --coverage-html coverage" + "coverage": "./vendor/bin/phpunit --coverage-html coverage", + "analyse": "./vendor/bin/phpstan analyse" } } diff --git a/composer.lock b/composer.lock index e911a49..099fe3a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1029f669ac7e87e8fee5fe897dcac979", + "content-hash": "a77599ab6321454c92b5fdae0f35739e", "packages": [ { "name": "doctrine/inflector", @@ -2694,6 +2694,307 @@ } ], "packages-dev": [ + { + "name": "composer/ca-bundle", + "version": "1.2.6", + "source": { + "type": "git", + "url": "https://github.com/composer/ca-bundle.git", + "reference": "47fe531de31fca4a1b997f87308e7d7804348f7e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/47fe531de31fca4a1b997f87308e7d7804348f7e", + "reference": "47fe531de31fca4a1b997f87308e7d7804348f7e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-pcre": "*", + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7 || 6.5 - 8", + "psr/log": "^1.0", + "symfony/process": "^2.5 || ^3.0 || ^4.0 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\CaBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "Lets you find a path to the system CA bundle, and includes a fallback to the Mozilla CA bundle.", + "keywords": [ + "cabundle", + "cacert", + "certificate", + "ssl", + "tls" + ], + "time": "2020-01-13T10:02:55+00:00" + }, + { + "name": "composer/composer", + "version": "1.9.3", + "source": { + "type": "git", + "url": "https://github.com/composer/composer.git", + "reference": "1291a16ce3f48bfdeca39d64fca4875098af4d7b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/composer/zipball/1291a16ce3f48bfdeca39d64fca4875098af4d7b", + "reference": "1291a16ce3f48bfdeca39d64fca4875098af4d7b", + "shasum": "" + }, + "require": { + "composer/ca-bundle": "^1.0", + "composer/semver": "^1.0", + "composer/spdx-licenses": "^1.2", + "composer/xdebug-handler": "^1.1", + "justinrainbow/json-schema": "^3.0 || ^4.0 || ^5.0", + "php": "^5.3.2 || ^7.0", + "psr/log": "^1.0", + "seld/jsonlint": "^1.4", + "seld/phar-utils": "^1.0", + "symfony/console": "^2.7 || ^3.0 || ^4.0", + "symfony/filesystem": "^2.7 || ^3.0 || ^4.0", + "symfony/finder": "^2.7 || ^3.0 || ^4.0", + "symfony/process": "^2.7 || ^3.0 || ^4.0" + }, + "conflict": { + "symfony/console": "2.8.38" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7", + "phpunit/phpunit-mock-objects": "^2.3 || ^3.0" + }, + "suggest": { + "ext-openssl": "Enabling the openssl extension allows you to access https URLs for repositories and packages", + "ext-zip": "Enabling the zip extension allows you to unzip archives", + "ext-zlib": "Allow gzip compression of HTTP requests" + }, + "bin": [ + "bin/composer" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\": "src/Composer" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "Composer helps you declare, manage and install dependencies of PHP projects. It ensures you have the right stack everywhere.", + "homepage": "https://getcomposer.org/", + "keywords": [ + "autoload", + "dependency", + "package" + ], + "time": "2020-02-04T11:58:49+00:00" + }, + { + "name": "composer/semver", + "version": "1.5.1", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "c6bea70230ef4dd483e6bbcab6005f682ed3a8de" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/c6bea70230ef4dd483e6bbcab6005f682ed3a8de", + "reference": "c6bea70230ef4dd483e6bbcab6005f682ed3a8de", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.5 || ^5.0.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "time": "2020-01-13T12:06:48+00:00" + }, + { + "name": "composer/spdx-licenses", + "version": "1.5.3", + "source": { + "type": "git", + "url": "https://github.com/composer/spdx-licenses.git", + "reference": "0c3e51e1880ca149682332770e25977c70cf9dae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/0c3e51e1880ca149682332770e25977c70cf9dae", + "reference": "0c3e51e1880ca149682332770e25977c70cf9dae", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7 || 6.5 - 7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Spdx\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "SPDX licenses list and validation library.", + "keywords": [ + "license", + "spdx", + "validator" + ], + "time": "2020-02-14T07:44:31+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "cbe23383749496fe0f373345208b79568e4bc248" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/cbe23383749496fe0f373345208b79568e4bc248", + "reference": "cbe23383749496fe0f373345208b79568e4bc248", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0", + "psr/log": "^1.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7 || 6.5 - 8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "time": "2019-11-06T16:40:04+00:00" + }, { "name": "doctrine/instantiator", "version": "1.3.0", @@ -2848,6 +3149,72 @@ ], "time": "2016-01-20T08:20:44+00:00" }, + { + "name": "justinrainbow/json-schema", + "version": "5.2.9", + "source": { + "type": "git", + "url": "https://github.com/justinrainbow/json-schema.git", + "reference": "44c6787311242a979fa15c704327c20e7221a0e4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/44c6787311242a979fa15c704327c20e7221a0e4", + "reference": "44c6787311242a979fa15c704327c20e7221a0e4", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", + "json-schema/json-schema-test-suite": "1.2.0", + "phpunit/phpunit": "^4.8.35" + }, + "bin": [ + "bin/validate-json" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "JsonSchema\\": "src/JsonSchema/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bruno Prieto Reis", + "email": "bruno.p.reis@gmail.com" + }, + { + "name": "Justin Rainbow", + "email": "justin.rainbow@gmail.com" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Robert Schönthal", + "email": "seroscho@googlemail.com" + } + ], + "description": "A library to validate a json schema.", + "homepage": "https://github.com/justinrainbow/json-schema", + "keywords": [ + "json", + "schema" + ], + "time": "2019-09-25T14:49:45+00:00" + }, { "name": "mockery/mockery", "version": "1.3.1", @@ -2961,6 +3328,78 @@ ], "time": "2019-12-15T19:12:40+00:00" }, + { + "name": "nunomaduro/larastan", + "version": "v0.5.2", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/larastan.git", + "reference": "a4cc1d5861123ea4c6b5c966a092063fb70a144b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/larastan/zipball/a4cc1d5861123ea4c6b5c966a092063fb70a144b", + "reference": "a4cc1d5861123ea4c6b5c966a092063fb70a144b", + "shasum": "" + }, + "require": { + "composer/composer": "^1.0", + "ext-json": "*", + "illuminate/console": "^6.0 || ^7.0", + "illuminate/container": "^6.0 || ^7.0", + "illuminate/contracts": "^6.0 || ^7.0", + "illuminate/database": "^6.0 || ^7.0", + "illuminate/http": "^6.0 || ^7.0", + "illuminate/pipeline": "^6.0 || ^7.0", + "illuminate/support": "^6.0 || ^7.0", + "mockery/mockery": "^0.9 || ^1.0", + "php": "^7.2", + "phpstan/phpstan": "^0.12", + "symfony/process": "^4.3 || ^5.0" + }, + "require-dev": { + "orchestra/testbench": "^4.0 || ^5.0", + "phpunit/phpunit": "^7.3 || ^8.2" + }, + "suggest": { + "orchestra/testbench": "^4.0 || ^5.0" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "psr-4": { + "NunoMaduro\\Larastan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan wrapper for Laravel", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "larastan", + "laravel", + "package", + "php", + "static analysis" + ], + "time": "2020-02-10T12:49:03+00:00" + }, { "name": "orchestra/testbench", "version": "v4.5.0", @@ -3392,6 +3831,45 @@ ], "time": "2019-12-22T21:05:45+00:00" }, + { + "name": "phpstan/phpstan", + "version": "0.12.11", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "ca5f2b7cf81c6d8fba74f9576970399c5817e03b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/ca5f2b7cf81c6d8fba74f9576970399c5817e03b", + "reference": "ca5f2b7cf81c6d8fba74f9576970399c5817e03b", + "shasum": "" + }, + "require": { + "php": "^7.1" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.12-dev" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "time": "2020-02-16T14:00:29+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "7.0.10", @@ -4342,6 +4820,149 @@ "homepage": "https://github.com/sebastianbergmann/version", "time": "2016-10-03T07:35:21+00:00" }, + { + "name": "seld/jsonlint", + "version": "1.7.2", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/jsonlint.git", + "reference": "e2e5d290e4d2a4f0eb449f510071392e00e10d19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/e2e5d290e4d2a4f0eb449f510071392e00e10d19", + "reference": "e2e5d290e4d2a4f0eb449f510071392e00e10d19", + "shasum": "" + }, + "require": { + "php": "^5.3 || ^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0" + }, + "bin": [ + "bin/jsonlint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Seld\\JsonLint\\": "src/Seld/JsonLint/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "JSON Linter", + "keywords": [ + "json", + "linter", + "parser", + "validator" + ], + "time": "2019-10-24T14:27:39+00:00" + }, + { + "name": "seld/phar-utils", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/phar-utils.git", + "reference": "8800503d56b9867d43d9c303b9cbcc26016e82f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/8800503d56b9867d43d9c303b9cbcc26016e82f0", + "reference": "8800503d56b9867d43d9c303b9cbcc26016e82f0", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Seld\\PharUtils\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be" + } + ], + "description": "PHAR file format utilities, for when PHP phars you up", + "keywords": [ + "phar" + ], + "time": "2020-02-14T15:25:33+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v4.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "266c9540b475f26122b61ef8b23dd9198f5d1cfd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/266c9540b475f26122b61ef8b23dd9198f5d1cfd", + "reference": "266c9540b475f26122b61ef8b23dd9198f5d1cfd", + "shasum": "" + }, + "require": { + "php": "^7.1.3", + "symfony/polyfill-ctype": "~1.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.4-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Filesystem Component", + "homepage": "https://symfony.com", + "time": "2020-01-21T08:20:44+00:00" + }, { "name": "theseer/tokenizer", "version": "1.1.3", diff --git a/config/apitizer.php b/config/apitizer.php index 665966a..1d8f20a 100644 --- a/config/apitizer.php +++ b/config/apitizer.php @@ -23,6 +23,14 @@ */ 'route_prefix' => 'apidoc', + /* + * Common validation settings. + */ + 'validation' => [ + 'date_format' => 'Y-m-d', + 'datetime_format' => DATE_ATOM, + ], + /* * Register the query builders of this project. */ diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..17286bc --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,15 @@ +includes: + - ./vendor/nunomaduro/larastan/extension.neon +parameters: + paths: + - src + # We can only starts increasing this number once we support only PHP 7.4+ due + # to the type hints on the return types in several builders: + # https://www.php.net/manual/en/language.oop5.variance.php + level: 8 + ignoreErrors: + - '#Unsafe usage of new static#' + excludes_analyse: + # This class does string-building with class-strings, which is more hassle + # to type-hint and annotate than it's worth. + - src/Apitizer/Support/ClassFilter.php diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php new file mode 100644 index 0000000..45d71bf --- /dev/null +++ b/resources/lang/en/validation.php @@ -0,0 +1,57 @@ + 'must be a "true" boolean value: yes, on, 1, or true', + 'active_url' => 'must have a valid A or AAAA DNS record', + 'after' => 'must be after :date', + 'after_or_equal' => 'must be equal to or after :date', + 'alpha' => 'must consist entirely of alphabetic characters', + 'alpha_dash' => 'must consist entirely of alpha-numeric characters', + 'before' => 'must be before :date', + 'before_or_equal' => 'must be equal to or before :date', + 'between' => 'must be between :min and :max', + 'confirmed' => 'There must be a field :field with the same value', + 'date_equals' => 'The date must be equal to :date', + 'different' => 'must be different from the value in field :field', + 'digits' => 'must be exactly :length digits long', + 'digits_between' => 'must be between :min and :max digits long', + 'dimensions' => 'The image must abide by the constraints', + 'distinct' => 'The array may not contain duplicate values', + 'email' => 'must be valid email', + 'ends_with' => 'must end with one of', + 'exists' => 'must exist on the server side', + 'image' => 'The file must be an image', + 'filled' => 'may not be empty if present', + 'gt' => 'must be greater than the value in :field', + 'gte' => 'must be greater than or equal to the value in :field', + 'lt' => 'must be less than the value in :field', + 'lte' => 'must be less than or equal to the value in :field', + 'in' => 'must be one of', + 'in_array' => 'must be in the array of field :field', + 'ip' => 'must be a valid IP address', + 'ipv4' => 'must be a valid IPv4 address', + 'ipv6' => 'must be a valid IPv6 address', + 'json' => 'must be a valid json string', + 'max' => 'must be less than or equal to :max', + 'min' => 'must be greater than or equal to :min', + 'mimetypes' => 'The file must have one of mimetypes', + 'mimes' => 'The file must have one of mimes', + 'not_in' => 'must not be included in: :values', + 'not_regex' => 'must not match the regex: :regex', + 'nullable' => 'may be nullable', + 'present' => 'The field must be present', + 'regex' => 'must match the regex: :regex', + 'required_unless' => 'is required unless :field has value: :value', + 'required_if' => 'The field is required if: :reason', + 'required_with' => 'The field is required only if :fields are present', + 'required_with_all' => 'The field is required only if all of :fields are present', + 'required_without' => 'The field is required only when :fields are not present', + 'required_without_all' => 'The field is required only when :fields are not present', + 'same' => 'must be the same as the value of field :field', + 'size' => 'must have a matching size with :size', + 'starts_with' => 'must start with one of', + 'timezone' => 'must be a valid timezone (e.g. Europe/Amsterdam)', + 'unique' => 'must be a unique resource on the server', + 'url' => 'must be a valid url', + 'uuid' => 'must be a valid UUID', +]; diff --git a/resources/views/documentation.blade.php b/resources/views/documentation.blade.php index a3e163d..89e8ca2 100644 --- a/resources/views/documentation.blade.php +++ b/resources/views/documentation.blade.php @@ -37,6 +37,7 @@ --separator-color: #dedede; --link-color: #1f5688; --link-hover-background-color: var(--darken); + --button-hover-color: var(--darken); --sidebar-width: 300px; --sidebar-background-color: var(--primary-color); @@ -193,10 +194,10 @@ .attributes-title, assoc-title { font-size: 18px; } - .attribute-name, .assoc-name { + .attribute-name, .assoc-name, .object-field-name { font-weight: 700; } - .attribute-type, .attribute-name { + .attribute-type, .attribute-name, .object-field-name, .object-field-type { font-family: monospace; } .attribute { @@ -221,6 +222,12 @@ .topic-content > .topic-section ~ .topic-section { margin-top: 40px; } + .endpoints-title { + font-size: 22px; + } + .endpoint-name { + font-size: 18px; + } .menu { position: fixed; top: 0; @@ -288,6 +295,54 @@ cursor: pointer; background-color: var(--lighten-lg); } + .object-rules { + border-radius: 5px; + margin-top: 10px; + } + .object-rules .object-rules { + border: 1px solid black; + padding: 10px; + } + .object-rules .object-meta { + display: inline-block; + padding: 10px 0; + font-size: 14px; + } + .object-field-name.required::after { + content: '*'; + color: red; + } + .validation-rules { + list-style: inside; + margin-left: 20px; + } + .tabs .tab-content { + display: none; + } + .tabs .tab [type="radio"]:checked ~ .tab-content { + display: initial; + } + .tabs [type="radio"] { + display: none; + } + .tabs .tab-toggle { + padding: 10px; + border: 1px solid var(--separator-color); + margin: 0; + flex-grow: 1; + } + .tabs .tab-toggle:hover { + background-color: var(--button-hover-color); + cursor: pointer; + } + .tabs .tab-toggle ~ .tab-toggle { + margin-left: -1px; + } + .tab-headings { + display: flex; + flex-direction: row; + align-content: center; + } @media (min-width: 768px) { .content { diff --git a/resources/views/resource_section.blade.php b/resources/views/resource_section.blade.php index 99008d1..69c8d41 100644 --- a/resources/views/resource_section.blade.php +++ b/resources/views/resource_section.blade.php @@ -88,5 +88,34 @@ @endif + @if ($doc->hasRules()) +
+

Validation

+ @foreach ($doc->getValidationBuilders() as $actionName => $builder) +
+

{{ $doc->humanizeActionName($actionName) }}

+
+ +
+
+ + +
+
+ +
+ @include('apitizer::validation_rules', ['builder' => $builder]) +
+
+
+ +
+ @include('apitizer::ts_interface', ['resourceName' => $doc->getName(), 'actionName' => $doc->humanizeActionName($actionName), 'builder' => $builder, 'depth' => 1]) +
+
+
+ @endforeach +
+ @endif diff --git a/resources/views/ts_interface.blade.php b/resources/views/ts_interface.blade.php new file mode 100644 index 0000000..11bcfae --- /dev/null +++ b/resources/views/ts_interface.blade.php @@ -0,0 +1,3 @@ +
+interface {{ $resourceName }}{{ $actionName }} {{ \Apitizer\Support\TsViewHelper::printObject($builder, $depth) }}
+
diff --git a/resources/views/ts_object.blade.php b/resources/views/ts_object.blade.php new file mode 100644 index 0000000..d501c2c --- /dev/null +++ b/resources/views/ts_object.blade.php @@ -0,0 +1,5 @@ +{ +@foreach ($builder->getChildren() as $field) +{{ str_repeat(' ', $depth) }}{{ $field->getFieldName() }}@if (!$field->isRequired())?@endif: {{ \Apitizer\Support\TsViewHelper::printableType($field, $depth) }}; +@endforeach +{{ str_repeat(' ', $depth - 1) }}} \ No newline at end of file diff --git a/resources/views/validation_field.blade.php b/resources/views/validation_field.blade.php new file mode 100644 index 0000000..096fb68 --- /dev/null +++ b/resources/views/validation_field.blade.php @@ -0,0 +1,21 @@ +
+ +
+ {{ $name }}: + {{ $field->getType() }} +
+
+ + @if ($field instanceof \Apitizer\Validation\ArrayRules) + @foreach ($field->getChildren() as $elementType) + @include('apitizer::validation_field', ['field' => $elementType, 'name' => $field->getFieldName() . '.*']) + @endforeach + @endif + @if ($field instanceof \Apitizer\Validation\ObjectRules) + @include('apitizer::validation_rules', ['builder' => $field]) + @endif +
diff --git a/resources/views/validation_rules.blade.php b/resources/views/validation_rules.blade.php new file mode 100644 index 0000000..05aed0a --- /dev/null +++ b/resources/views/validation_rules.blade.php @@ -0,0 +1,9 @@ +
+ +
diff --git a/src/Apitizer/Apitizer.php b/src/Apitizer/Apitizer.php index 86809f7..f33a464 100644 --- a/src/Apitizer/Apitizer.php +++ b/src/Apitizer/Apitizer.php @@ -3,6 +3,7 @@ namespace Apitizer; use Apitizer\Types\ApidocCollection; +use Apitizer\Types\Apidoc; class Apitizer { @@ -19,7 +20,7 @@ public static function getQueryBuilders(): array /** * Get the documentation for each registered query builder. * - * @return ApidocCollection + * @return ApidocCollection */ public static function getQueryBuilderDocumentation(): ApidocCollection { @@ -70,7 +71,7 @@ public static function getLimitKey(): string /** * Get the mapping of all query params. * - * @return array + * @return array * * @see Apitizer::getFieldKey * @see Apitizer::getSortKey diff --git a/src/Apitizer/Commands/ValidateSchemaCommand.php b/src/Apitizer/Commands/ValidateSchemaCommand.php index 442f6ef..d9e0b10 100644 --- a/src/Apitizer/Commands/ValidateSchemaCommand.php +++ b/src/Apitizer/Commands/ValidateSchemaCommand.php @@ -26,9 +26,12 @@ public function __construct(SchemaValidator $schemaValidator) $this->schemaValidator = $schemaValidator; } - public function handle() + public function handle(): ?int { - if ($builderClass = $this->argument('builderClass')) { + /** @var string $builderClass */ + $builderClass = $this->argument('builderClass'); + + if ($builderClass) { if (! class_exists($builderClass)) { $this->error("The given class [$builderClass] could not be found"); return 1; @@ -55,7 +58,7 @@ public function handle() return 0; } - public function printErrors(SchemaValidator $schemaValidator) + public function printErrors(SchemaValidator $schemaValidator): void { $errors = collect($schemaValidator->getErrors()); @@ -88,7 +91,7 @@ public function printErrors(SchemaValidator $schemaValidator) } $this->comment($this->listItem(Str::title($namespace))); - /** @var DefinitionException */ + /** @var DefinitionException $e */ foreach ($errors[$namespace] as $e) { $this->line($this->listItem($e->getMessage(), 2)); } @@ -96,7 +99,7 @@ public function printErrors(SchemaValidator $schemaValidator) }); } - private function printException(Exception $e) + private function printException(Exception $e): void { $file = $e->getFile(); $line = $e->getLine(); @@ -105,13 +108,13 @@ private function printException(Exception $e) $this->error($e->getTraceAsString(), 'v'); } - private function section(string $text) + private function section(string $text): void { $this->comment($text); $this->comment(str_repeat('-', strlen($text))); } - private function listItem(string $text, int $depth = 0) + private function listItem(string $text, int $depth = 0): string { return str_repeat(' ', $depth) . '* ' . $text; } diff --git a/src/Apitizer/Concerns/HasFields.php b/src/Apitizer/Concerns/HasFields.php index 5e8d331..20e3f6c 100644 --- a/src/Apitizer/Concerns/HasFields.php +++ b/src/Apitizer/Concerns/HasFields.php @@ -45,7 +45,7 @@ protected function boolean(string $key): Field return $this->field($key, 'boolean')->transform(new CastValue); } - protected function date(string $key, $castFormat = null): DateTimeField + protected function date(string $key, string $castFormat = null): DateTimeField { return (new DateTimeField($this, $key, 'date')) ->transform(new CastValue($castFormat)); @@ -57,6 +57,11 @@ protected function datetime(string $key, string $castFormat = null): DateTimeFie ->transform(new CastValue($castFormat)); } + /** + * @param string $key + * @param array $enum + * @param string $type + */ protected function enum(string $key, array $enum, string $type = 'string'): EnumField { return (new EnumField($this, $key, $enum, $type)) diff --git a/src/Apitizer/Controllers/DocumentationController.php b/src/Apitizer/Controllers/DocumentationController.php index 1d5e45a..e6ff9fb 100644 --- a/src/Apitizer/Controllers/DocumentationController.php +++ b/src/Apitizer/Controllers/DocumentationController.php @@ -8,6 +8,9 @@ class DocumentationController { + /** + * @return \Illuminate\View\View|\Illuminate\Contracts\View\Factory + */ public function list(Request $request) { $apidoc = Apitizer::getQueryBuilderDocumentation(); diff --git a/src/Apitizer/Exceptions/CastException.php b/src/Apitizer/Exceptions/CastException.php index 836f758..fed8149 100644 --- a/src/Apitizer/Exceptions/CastException.php +++ b/src/Apitizer/Exceptions/CastException.php @@ -22,6 +22,11 @@ class CastException extends ApitizerException */ public $format; + /** + * @param mixed $value + * @param string $type + * @param string|null $format + */ public function __construct($value, string $type, string $format = null) { parent::__construct(); diff --git a/src/Apitizer/Exceptions/ClassFinderException.php b/src/Apitizer/Exceptions/ClassFinderException.php index c2cbc94..8dc652f 100644 --- a/src/Apitizer/Exceptions/ClassFinderException.php +++ b/src/Apitizer/Exceptions/ClassFinderException.php @@ -4,12 +4,12 @@ class ClassFinderException extends ApitizerException { - public static function composerFileNotFound(string $path) + public static function composerFileNotFound(string $path): self { return new static("Could not find composer file on path [$path]"); } - public static function psr4NotFound(string $composerPath) + public static function psr4NotFound(string $composerPath): self { return new static("Could not find PSR-4 definition in [$composerPath]"); } diff --git a/src/Apitizer/Exceptions/DefinitionException.php b/src/Apitizer/Exceptions/DefinitionException.php index f7d59e7..39871fd 100644 --- a/src/Apitizer/Exceptions/DefinitionException.php +++ b/src/Apitizer/Exceptions/DefinitionException.php @@ -5,6 +5,7 @@ use Apitizer\QueryBuilder; use Apitizer\Types\Association; use Apitizer\Types\Filter; +use Apitizer\Types\Sort; /** * This exception occurs when the programmer gives an unexpected definition in @@ -25,7 +26,7 @@ class DefinitionException extends ApitizerException const NAMESPACES = ['association', 'field', 'filter', 'sort']; /** - * @var string The field/sort/filter name where this exception occured. + * @var string|null The field/sort/filter name where this exception occured. */ protected $name; @@ -41,6 +42,11 @@ public function __construct( $this->name = $name; } + /** + * @param QueryBuilder $queryBuilder + * @param string $key + * @param mixed $given + */ static function builderClassExpected(QueryBuilder $queryBuilder, string $key, $given): self { $class = get_class($queryBuilder); @@ -63,6 +69,11 @@ static function associationDoesNotExist(QueryBuilder $queryBuilder, Association return new static($message, $queryBuilder, 'association', $associaton->getName()); } + /** + * @param QueryBuilder $queryBuilder + * @param string $name + * @param mixed $given + */ static function fieldDefinitionExpected(QueryBuilder $queryBuilder, string $name, $given): self { $class = get_class($queryBuilder); @@ -73,6 +84,11 @@ static function fieldDefinitionExpected(QueryBuilder $queryBuilder, string $name return new static($message, $queryBuilder, 'field', $name); } + /** + * @param QueryBuilder $queryBuilder + * @param string $name + * @param mixed $given + */ static function filterDefinitionExpected(QueryBuilder $queryBuilder, string $name, $given): self { $class = get_class($queryBuilder); @@ -92,6 +108,11 @@ static function filterHandlerNotDefined(QueryBuilder $queryBuilder, Filter $filt return new static($message, $queryBuilder, 'filter', $filter->getName()); } + /** + * @param QueryBuilder $queryBuilder + * @param string $name + * @param mixed $given + */ static function sortDefinitionExpected(QueryBuilder $queryBuilder, string $name, $given): self { $class = get_class($queryBuilder); @@ -102,6 +123,14 @@ static function sortDefinitionExpected(QueryBuilder $queryBuilder, string $name, return new static($message, $queryBuilder, 'sort', $name); } + static function sortHandlerNotDefined(QueryBuilder $queryBuilder, string $name): self + { + $class = get_class($queryBuilder); + $message = "Expected a callable handler to be defined for sort [$name] on [$class]"; + + return new static($message, $queryBuilder, 'sort', $name); + } + public function getQueryBuilder(): QueryBuilder { return $this->queryBuilder; @@ -112,7 +141,7 @@ public function getNamespace(): string return $this->namespace; } - public function getName(): string + public function getName(): ?string { return $this->name; } diff --git a/src/Apitizer/Exceptions/InvalidInputException.php b/src/Apitizer/Exceptions/InvalidInputException.php index fa32570..527d74d 100644 --- a/src/Apitizer/Exceptions/InvalidInputException.php +++ b/src/Apitizer/Exceptions/InvalidInputException.php @@ -4,6 +4,7 @@ use Apitizer\Apitizer; use Apitizer\Types\Filter; +use Apitizer\Types\Sort; use Apitizer\Types\Factory; use Apitizer\QueryBuilder; @@ -29,7 +30,16 @@ class InvalidInputException extends ApitizerException */ public $origin; - public static function filterTypeError(Filter $filter, $given) + /** + * @var Filter|Sort + */ + public $type; + + /** + * @param Filter $filter + * @param mixed $given + */ + public static function filterTypeError(Filter $filter, $given): self { $filterKey = Apitizer::getFilterKey(); $filterName = $filter->getName(); diff --git a/src/Apitizer/Exceptions/InvalidOutputException.php b/src/Apitizer/Exceptions/InvalidOutputException.php index 37f7183..d04eb40 100644 --- a/src/Apitizer/Exceptions/InvalidOutputException.php +++ b/src/Apitizer/Exceptions/InvalidOutputException.php @@ -3,7 +3,8 @@ namespace Apitizer\Exceptions; use Apitizer\Types\EnumField; -use Apitizer\Types\Field; +use Apitizer\QueryBuilder; +use Apitizer\Types\AbstractField; use Illuminate\Database\Eloquent\Model; use ArrayAccess; @@ -27,11 +28,15 @@ class InvalidOutputException extends ApitizerException /** * The class from which the exception originates * - * @var Field|EnumField + * @var AbstractField */ public $origin; - public static function fieldIsNull(Field $field, $row): self + /** + * @param AbstractField $field + * @param mixed $row + */ + public static function fieldIsNull(AbstractField $field, $row): self { $fieldName = $field->getName(); $queryBuilderName = get_class($field->getQueryBuilder()); @@ -46,6 +51,11 @@ public static function fieldIsNull(Field $field, $row): self return $e; } + /** + * @param EnumField $field + * @param mixed $value + * @param mixed $row + */ public static function invalidEnum(EnumField $field, $value, $row): self { $fieldName = $field->getName(); @@ -61,7 +71,12 @@ public static function invalidEnum(EnumField $field, $value, $row): self return $e; } - public static function castError(Field $field, CastException $e, $row) + /** + * @param AbstractField $field + * @param CastException $e + * @param mixed $row + */ + public static function castError(AbstractField $field, CastException $e, $row): self { $fieldName = $field->getName(); $queryBuilderName = get_class($field->getQueryBuilder()); @@ -84,6 +99,8 @@ public static function castError(Field $field, CastException $e, $row) * * The entire row of data cannot be used because there might be sensitive * data in it that should not be logged outside of the system. + * + * @param mixed $row */ public static function rowReference($row): string { diff --git a/src/Apitizer/Filters/AssociationFilter.php b/src/Apitizer/Filters/AssociationFilter.php index 87e39b7..37247be 100644 --- a/src/Apitizer/Filters/AssociationFilter.php +++ b/src/Apitizer/Filters/AssociationFilter.php @@ -22,7 +22,7 @@ class AssociationFilter /** * @param string $relation the name of the relation on the model. - * @param null|string the column name that should be compared. Defaults to + * @param null|string $column the column name that should be compared. Defaults to * the related model's primary key. */ public function __construct(string $relation, ?string $column = null) @@ -31,25 +31,36 @@ public function __construct(string $relation, ?string $column = null) $this->column = $column; } - public function __invoke(Builder $query, $values) + /** + * @param Builder $query + * @param string|string[] $values + */ + public function __invoke(Builder $query, $values): void { $values = Arr::wrap($values); $relation = $query->getModel()->{$this->relation}(); // Default to the primary key on the related table if no column was // given. - $this->column = $this->column ?? $relation->getRelated()->getKeyName(); + $column = $this->column ?? $relation->getRelated()->getKeyName(); if ($relation instanceof BelongsTo || $relation instanceof HasOneOrMany) { - $this->applyFilter($query, $values, $relation); + $this->applyFilter($query, $values, $relation, $column); } else { - $this->inefficientFilter($query, $values); + $this->inefficientFilter($query, $values, $column); } } - protected function applyFilter(Builder $query, array $values, Relation $relation) + /** + * @param Builder $query + * @param string[] $values + * @param BelongsTo|HasOneOrMany $relation + */ + protected function applyFilter(Builder $query, array $values, Relation $relation, string $column): void { - $table = $relation->getQuery()->getModel()->getTable(); + /** @var \Illuminate\Database\Eloquent\Model */ + $model = $relation->getQuery()->getModel(); + $table = $model->getTable(); $localJoinKey = $this->getLocalJoinKey($relation); $foreignJoinKey = $this->getForeignJoinKey($relation); @@ -59,15 +70,15 @@ protected function applyFilter(Builder $query, array $values, Relation $relation // use the posts.author_id directly. // From: select * from posts where author_id in (select id from users where id in (VALUES)) // To : select * from posts where author_id in (VALUES) - if ($relation instanceof BelongsTo && $foreignJoinKey === $this->column) { + if ($relation instanceof BelongsTo && $foreignJoinKey === $column) { $query->whereIn($localJoinKey, $values); return; } - $query->whereIn($localJoinKey, function ($query) use ($values, $table, $foreignJoinKey) { + $query->whereIn($localJoinKey, function ($query) use ($values, $table, $foreignJoinKey, $column) { $query->from($table) ->select($foreignJoinKey) - ->whereIn($this->column, $values); + ->whereIn($column, $values); }); } @@ -91,12 +102,16 @@ protected function getForeignJoinKey(Relation $relation): string : $relation->getForeignKeyName(); } - protected function inefficientFilter(Builder $query, array $values) + /** + * @param Builder $query + * @param string[] $values + */ + protected function inefficientFilter(Builder $query, array $values, string $column): void { // whereHas translates to a WHERE EXISTS which often results in a full // table scan (at least on MySQL). - return $query->whereHas($this->relation, function (Builder $query) use ($values) { - $query->whereIn($this->column, $values); + $query->whereHas($this->relation, function (Builder $query) use ($values, $column) { + $query->whereIn($column, $values); }); } } diff --git a/src/Apitizer/Filters/LikeFilter.php b/src/Apitizer/Filters/LikeFilter.php index 4e9c76b..5df2603 100644 --- a/src/Apitizer/Filters/LikeFilter.php +++ b/src/Apitizer/Filters/LikeFilter.php @@ -6,14 +6,20 @@ class LikeFilter { + /** + * @var string[] + */ protected $fields; + /** + * @param string|string[] $fields + */ public function __construct($fields) { $this->fields = is_array($fields) ? $fields : func_get_args(); } - public function __invoke(Builder $query, string $value) + public function __invoke(Builder $query, string $value): void { $searchTerm = '%' . $value . '%'; diff --git a/src/Apitizer/Interpreter/QueryInterpreter.php b/src/Apitizer/Interpreter/QueryInterpreter.php index 4f75c8c..4b4b15e 100644 --- a/src/Apitizer/Interpreter/QueryInterpreter.php +++ b/src/Apitizer/Interpreter/QueryInterpreter.php @@ -5,7 +5,10 @@ use Apitizer\Types\FetchSpec; use Apitizer\QueryBuilder; use Apitizer\Types\Field; +use Apitizer\Types\AbstractField; use Apitizer\Types\Association; +use Apitizer\Types\Filter; +use Apitizer\Types\Sort; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasOneOrMany; @@ -35,11 +38,19 @@ public function build(QueryBuilder $queryBuilder, FetchSpec $fetchSpec): Builder return $query; } - private function applySelect(Builder $query, array $fields, array $additionalSelects = []) + /** + * @param Builder $query + * @param (AbstractField|Association)[] $fields + * @param string[] $additionalSelects + */ + private function applySelect(Builder $query, array $fields, array $additionalSelects = []): void { + /** @var \Illuminate\Database\Eloquent\Model $model */ + $model = $query->getModel(); + // Always load the primary key in case there are relationships that // depend on it. - $selectKeys = array_merge([$query->getModel()->getKeyName()], $additionalSelects); + $selectKeys = array_merge([$model->getKeyName()], $additionalSelects); foreach ($fields as $fieldOrAssoc) { if ($fieldOrAssoc instanceof Field) { @@ -48,7 +59,7 @@ private function applySelect(Builder $query, array $fields, array $additionalSel } else if ($fieldOrAssoc instanceof Association) { // We also need to ensure that we always load the right foreign // keys, otherwise we won't be able load relationships. - $relationship = $query->getModel()->{$fieldOrAssoc->getKey()}(); + $relationship = $model->{$fieldOrAssoc->getKey()}(); // Perhaps we could even eager load belongsTo relationships // in-line using a join and table aliases, since there's always @@ -75,7 +86,7 @@ private function applySelect(Builder $query, array $fields, array $additionalSel $this->applySelect( $relation->getQuery(), - $fieldOrAssoc->getFields(), + $fieldOrAssoc->getFields() ?? [], $additionalSelects ); }] @@ -86,7 +97,11 @@ private function applySelect(Builder $query, array $fields, array $additionalSel $query->select(array_unique($selectKeys)); } - private function applySorting(Builder $query, array $sorts) + /** + * @param Builder $query + * @param Sort[] $sorts + */ + private function applySorting(Builder $query, array $sorts): void { foreach ($sorts as $sort) { if ($handler = $sort->getHandler()) { @@ -95,7 +110,11 @@ private function applySorting(Builder $query, array $sorts) } } - private function applyFilters(Builder $query, array $filters) + /** + * @param Builder $query + * @param Filter[] $filters + */ + private function applyFilters(Builder $query, array $filters): void { foreach ($filters as $filter) { if ($handler = $filter->getHandler()) { diff --git a/src/Apitizer/Parser/Context.php b/src/Apitizer/Parser/Context.php index 3402642..33ffe57 100644 --- a/src/Apitizer/Parser/Context.php +++ b/src/Apitizer/Parser/Context.php @@ -5,7 +5,8 @@ class Context { /** - * The output buffer to which the string are appended for the current context; + * @var string The output buffer to which the string are appended for the + * current context; */ public $accumulator = ''; @@ -13,6 +14,8 @@ class Context * A place to hold information regarding the current context. * * In the case of field parsing, this might be the fields up until now. + * + * @var (string|Relation)[] */ public $stack = []; @@ -25,6 +28,8 @@ class Context * "id,name,comments(id,body)" * * to have their own context for the inner braces in the "comments". + * + * @var Context|null */ public $parent = null; @@ -33,6 +38,8 @@ class Context * * Inside of quoted expressions, meta characters such as , and ( ) are * ignored until the quote is closed. + * + * @var bool */ public $isQuoted = false; @@ -40,7 +47,7 @@ public function __construct(Context $parent = null) { $this->parent = $parent; } - public function makeChildContext(): self + public function makeChildContext(): Context { return new self($this); } diff --git a/src/Apitizer/Parser/InputParser.php b/src/Apitizer/Parser/InputParser.php index 09bbe21..c0a7179 100644 --- a/src/Apitizer/Parser/InputParser.php +++ b/src/Apitizer/Parser/InputParser.php @@ -24,7 +24,7 @@ public function parse(RawInput $rawInput): ParsedInput } /** - * @param string|array $fields + * @param string|string[]|mixed $rawFields * @return (string|Relation)[] */ public function parseFields($rawFields): array @@ -40,9 +40,17 @@ public function parseFields($rawFields): array return $rawFields; } + if (! is_string($rawFields)) { + return []; + } + $context = new Context(); - foreach ($this->stringToArray($rawFields) as $character) { + if (! $characters = $this->stringToArray($rawFields)) { + return []; + } + + foreach ($characters as $character) { if ($context->isQuoted && $character !== '"') { $context->accumulator .= $character; continue; @@ -71,6 +79,9 @@ public function parseFields($rawFields): array // Add remainder to the current stack. $context->stack[] = $context->accumulator; + // For phpstan to understand that parent is filled at this point. + assert($context->parent !== null); + // The parent's accumulator currently holds anything up until // the (, which should be the relationship name $context->parent->stack[] = new Relation($context->parent->accumulator, $context->stack); @@ -94,7 +105,9 @@ public function parseFields($rawFields): array } /** - * @param array|null $rawFilters + * @param mixed|array|null $rawFilters + * + * @return array */ public function parseFilters($rawFilters): array { @@ -109,7 +122,9 @@ public function parseFilters($rawFilters): array } /** - * @param array|string $rawSorts + * @param mixed|string[]|string $rawSorts + * + * @return Sort[] */ public function parseSorts($rawSorts): array { @@ -155,6 +170,9 @@ public function parseSorts($rawSorts): array return $sorts; } + /** + * @return string[]|false + */ protected function stringToArray(string $raw) { return preg_split('//u', $raw, null, PREG_SPLIT_NO_EMPTY); diff --git a/src/Apitizer/Parser/ParsedInput.php b/src/Apitizer/Parser/ParsedInput.php index 60c87e2..8de6f4a 100644 --- a/src/Apitizer/Parser/ParsedInput.php +++ b/src/Apitizer/Parser/ParsedInput.php @@ -16,8 +16,7 @@ class ParsedInput public $sorts = []; /** - * @var array an associative array where the key is the name of the filter, - * and the value is just the value of that filter. + * @var array */ public $filters = []; } diff --git a/src/Apitizer/Parser/RawInput.php b/src/Apitizer/Parser/RawInput.php index b5af2f2..bc95e46 100644 --- a/src/Apitizer/Parser/RawInput.php +++ b/src/Apitizer/Parser/RawInput.php @@ -7,11 +7,26 @@ class RawInput { - // No types are given since this is user input and could therefore be anything. + /** + * @var mixed + */ protected $fields; + + /** + * @var mixed + */ protected $filters; + + /** + * @var mixed + */ protected $sorts; + /** + * @param mixed $fields + * @param mixed $filters + * @param mixed $sorts + */ public function __construct($fields, $filters, $sorts) { $this->fields = $fields; @@ -28,6 +43,9 @@ public static function fromRequest(Request $request): self ); } + /** + * @param array{fields: string|string[], filters: array, sorts: string|string[]} $input + */ public static function fromArray(array $input): self { return new static( @@ -37,16 +55,25 @@ public static function fromArray(array $input): self ); } + /** + * @return mixed + */ public function getFields() { return $this->fields; } + /** + * @return mixed + */ public function getFilters() { return $this->filters; } + /** + * @return mixed + */ public function getSorts() { return $this->sorts; diff --git a/src/Apitizer/Parser/Relation.php b/src/Apitizer/Parser/Relation.php index dac9d47..5a48921 100644 --- a/src/Apitizer/Parser/Relation.php +++ b/src/Apitizer/Parser/Relation.php @@ -7,9 +7,13 @@ class Relation /** @var string */ public $name; - /** @var array */ + /** @var array */ public $fields; + /** + * @param string $name + * @param array $fields + */ public function __construct(string $name, array $fields) { $this->name = $name; $this->fields = $fields; diff --git a/src/Apitizer/Parser/Sort.php b/src/Apitizer/Parser/Sort.php index c7ccdc0..d53601c 100644 --- a/src/Apitizer/Parser/Sort.php +++ b/src/Apitizer/Parser/Sort.php @@ -31,7 +31,7 @@ public function getField(): string } /** - * @return 'asc'|'desc' + * @return string 'asc' | 'desc' */ public function getOrder(): string { diff --git a/src/Apitizer/Policies/Policy.php b/src/Apitizer/Policies/Policy.php index 64b5f19..5d0aff1 100644 --- a/src/Apitizer/Policies/Policy.php +++ b/src/Apitizer/Policies/Policy.php @@ -2,7 +2,7 @@ namespace Apitizer\Policies; -use Apitizer\Types\Field; +use Apitizer\Types\AbstractField; use Apitizer\Types\Association; use Illuminate\Database\Eloquent\Model; @@ -26,10 +26,11 @@ interface Policy * can be used to render just about any data. This should be taken into * account when writing a policy. * - * @param Field|Association $fieldOrAssoc the field or association instance - * that is currently being rendered. This instance also holds a reference to - * the current query builder if that is needed in the policy. Furthermore, - * the request instance can also be fetched from that query builder. + * @param AbstractField|Association $fieldOrAssoc the field or association + * instance that is currently being rendered. This instance also holds a + * reference to the current query builder if that is needed in the policy. + * Furthermore, the request instance can also be fetched from that query + * builder. */ public function passes($value, $row, $fieldOrAssoc): bool; } diff --git a/src/Apitizer/QueryBuilder.php b/src/Apitizer/QueryBuilder.php index 386915b..150b62d 100644 --- a/src/Apitizer/QueryBuilder.php +++ b/src/Apitizer/QueryBuilder.php @@ -7,7 +7,6 @@ use Apitizer\Exceptions\InvalidInputException; use Apitizer\ExceptionStrategy\Strategy; use Apitizer\Interpreter\QueryInterpreter; -use Apitizer\Parser\InputParser; use Apitizer\Parser\ParsedInput; use Apitizer\Parser\Parser; use Apitizer\Parser\RawInput; @@ -20,6 +19,9 @@ use Apitizer\Types\AbstractField; use Apitizer\Types\Filter; use Apitizer\Types\Sort; +use Apitizer\Validation\Rules; +use Illuminate\Contracts\Validation\Validator; +use Illuminate\Support\Facades\Validator as ValidatorFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; @@ -44,49 +46,52 @@ abstract class QueryBuilder * * This can be especially helpful when you have policies or other checks * that depend on certain data being present. + * + * @var string[] */ protected $alwaysLoadColumns = []; /** - * @var InputParser + * @var Parser|null */ protected $parser; /** - * @var Renderer + * @var Renderer|null */ protected $renderer; /** - * @var QueryInterpreter + * @var QueryInterpreter|null */ protected $queryInterpreter; /** * The result of the fields() callback. * - * @var AbstractField[]|Association[] + * @var (AbstractField|Association)[]|null */ protected $availableFields; /** * The results of the sorts() function. * - * @var Sort[] + * @var array|null */ protected $availableSorts; /** * The results of the filters() function. * - * @var Filter[] + * @var array|null */ protected $availableFilters; /** - * @var array|null the specification that should be used when fetching or - * rendering data. This is an alternative to the input from the Request - * object; therefore, it this is null, the request's input will be used. + * @var array{fields: string|string[], sorts: string|string[], filters: array}|null + * the specification that should be used when fetching or rendering data. + * This is an alternative to the input from the Request object; therefore, + * it this is null, the request's input will be used. */ protected $specification; @@ -104,17 +109,22 @@ abstract class QueryBuilder /** * The maximum number of rows that the client is able to request. * - * @param int + * @var int */ protected $maximumLimit = 50; /** * The strategy to use when an exception is raised. * - * @var Strategy + * @var Strategy|null */ protected $exceptionStrategy; + /** + * @var Rules|null + */ + protected $rules; + /** * A function that returns the fields that are available to the client. * @@ -123,6 +133,8 @@ abstract class QueryBuilder * Each type (e.g. `$this->string`) expects at least a key string. This key * is used to fetch the data from the Eloquent model, so it usually * corresponds to the column name in the database. + * + * @return array */ abstract public function fields(): array; @@ -140,6 +152,8 @@ abstract public function fields(): array; * * @see $this->sort() * @see \Apitizer\Types\Sort + * + * @return array */ abstract public function sorts(): array; @@ -151,6 +165,8 @@ abstract public function sorts(): array; * * @see $this->filter() * @see \Apitizer\Types\Filter + * + * @return array */ abstract public function filters(): array; @@ -159,13 +175,22 @@ abstract public function filters(): array; */ abstract public function model(): Model; + /** + * Build the validation rules for the various route actions. + * + * @param Rules $rules + * + * @return void + */ + abstract public function rules(Rules $rules); + /** * Overridable function to adjust the API documentation for this query * builder. * * @see Apidoc */ - public function apidoc(Apidoc $apidoc) + public function apidoc(Apidoc $apidoc): void { // } @@ -207,7 +232,7 @@ public function __construct(Request $request = null) { /** * Static alias for the constructor. */ - public static function make(Request $request = null) + public static function make(Request $request = null): QueryBuilder { return (new static($request)); } @@ -223,13 +248,14 @@ public static function build(Request $request = null): Builder } /** - * @param array the specification of data that should be used for this - * builder. This array may contain three keys: `fields`, `filters`, and - * `sorts`. The value for these should be the same as what you would send in - * a request; in other words, fields may be a comma separated string of - * fields (or an array), etc. + * @param array{fields: string|string[], sorts: string|string[], filters: array} $specification + * array the specification of data that should be used for this builder. + * This array may contain three keys: `fields`, `filters`, and `sorts`. The + * value for these should be the same as what you would send in a request; + * in other words, fields may be a comma separated string of fields (or an + * array), etc. */ - public function fromSpecification(array $specification) + public function fromSpecification(array $specification): self { $this->specification = $specification; @@ -249,7 +275,7 @@ public function fromSpecification(array $specification) * $this->association('comments', CommentBuilder::class) * * @param string $key - * @param string $builder + * @param string $builderClass * * @return Association */ @@ -297,7 +323,7 @@ protected function sort(): Sort * * @param mixed $data * - * @return array + * @return array>|array */ public function render($data): array { @@ -309,7 +335,7 @@ public function render($data): array /** * Fetch and render all the data. * - * @return array + * @return array */ public function all(): array { @@ -325,15 +351,19 @@ public function all(): array /** * Fetch and return paginated data. * - * @return LengthAwarePaginator + * @param int $perPage + * @param string $pageName + * @param int|null $page + * + * @return LengthAwarePaginator */ - public function paginate(int $perPage = null, ...$rest): LengthAwarePaginator + public function paginate(int $perPage = null, $pageName = 'page', $page = null): LengthAwarePaginator { $fetchSpec = $this->makeFetchSpecification(); $perPage = $this->getPerPage($perPage); $paginator = $this->getQueryInterpreter() ->build($this, $fetchSpec) - ->paginate($perPage, ...$rest); + ->paginate($perPage, [], $pageName, $page); return tap($paginator, function (AbstractPaginator $paginator) use ($fetchSpec) { $renderedData = $this->getRenderer()->render( @@ -342,17 +372,20 @@ public function paginate(int $perPage = null, ...$rest): LengthAwarePaginator $paginator->setCollection(collect($renderedData)); + /** @var array $queryParameters */ + $queryParameters = $this->getRequest()->query(); + // Ensure the all the supported query parameters that were passed in are // also present in the pagination links. $queryParameters = Arr::only( - $this->getRequest()->query(), + $queryParameters, array_values(Apitizer::getQueryParams()) ); $paginator->appends($queryParameters); }); } - protected function getPerPage(int $perPage = null) + protected function getPerPage(int $perPage = null): ?int { $limitKey = Apitizer::getLimitKey(); $request = $this->getRequest(); @@ -380,6 +413,40 @@ public function buildQuery(): Builder ->build($this, $this->makeFetchSpecification()); } + /** + * Get the validation rules for the current request. + * + * @return array + */ + public function validationRules(): array + { + /** @var \Illuminate\Routing\Route $route */ + $route = $this->getRequest()->route(); + $actionMethod = $route->getActionMethod(); + + return $this->getRules()->getValidationRulesForAction($actionMethod); + } + + /** + * Get an instantiated validator object with the rules for the current + * action method. + */ + public function validator(): Validator + { + return ValidatorFactory::make($this->getRequest()->all(), $this->validationRules()); + } + + /** + * Return the validated data for the current request, based on the request's + * action method. + * + * @return array + */ + public function validated(): array + { + return $this->validator()->validate(); + } + /** * Build the fetch specification based on the query builder and the request. * @@ -412,7 +479,10 @@ protected function validateRequestInput(ParsedInput $unvalidatedInput): FetchSpe /** * Validate the fields that were requested by the client. * - * @return [string => AbstractField|Association] + * @param (string|Relation)[] $unvalidatedFields + * @param array $availableFields + * + * @return array */ protected function getValidatedFields(array $unvalidatedFields, array $availableFields): array { @@ -461,6 +531,12 @@ protected function getValidatedFields(array $unvalidatedFields, array $available return $validatedFields; } + /** + * @param \Apitizer\Parser\Sort[] $selectedSorts + * @param Sort[] $availableSorts + * + * @return Sort[] + */ protected function getValidatedSorting(array $selectedSorts, array $availableSorts): array { $validatedSorts = []; @@ -476,6 +552,12 @@ protected function getValidatedSorting(array $selectedSorts, array $availableSor return $validatedSorts; } + /** + * @param array $selectedFilters + * @param array $availableFilters + * + * @return array + */ protected function getValidatedFilters(array $selectedFilters, array $availableFilters): array { $validatedFilters = []; @@ -495,6 +577,9 @@ protected function getValidatedFilters(array $selectedFilters, array $availableF return $validatedFilters; } + /** + * @return array + */ public function getFields(): array { if (is_null($this->availableFields)) { @@ -504,6 +589,9 @@ public function getFields(): array return $this->availableFields; } + /** + * @return array + */ public function getSorts(): array { if (is_null($this->availableSorts)) { @@ -513,6 +601,9 @@ public function getSorts(): array return $this->availableSorts; } + /** + * @return array + */ public function getFilters(): array { if (is_null($this->availableFilters)) { @@ -523,7 +614,7 @@ public function getFilters(): array } /** - * @return Field[] + * @return AbstractField[] */ public function getOnlyFields(): array { @@ -633,9 +724,9 @@ public function setExceptionStrategy(?Strategy $strategy): self return $this; } - public function handleException(ApitizerException $e) + public function handleException(ApitizerException $e): void { - return $this->getExceptionStrategy()->handle($this, $e); + $this->getExceptionStrategy()->handle($this, $e); } public function getParent(): ?QueryBuilder @@ -666,8 +757,24 @@ public function setMaximumLimit(int $limit): self return $this; } + /** + * @return string[] + */ public function getAlwaysLoadColumns(): array { return $this->alwaysLoadColumns; } + + public function getRules(): Rules + { + $rules = $this->rules; + + if (! $rules) { + $rules = new Rules(); + $this->rules($rules); + $this->rules = $rules; + } + + return $rules; + } } diff --git a/src/Apitizer/QueryBuilderLoader.php b/src/Apitizer/QueryBuilderLoader.php index a70005d..14c9d09 100644 --- a/src/Apitizer/QueryBuilderLoader.php +++ b/src/Apitizer/QueryBuilderLoader.php @@ -2,7 +2,6 @@ namespace Apitizer; -use Apitizer\Exceptions\ClassFinderException; use Apitizer\QueryBuilder; use Apitizer\Support\ComposerNamespaceClassFinder; @@ -14,7 +13,7 @@ class QueryBuilderLoader { /** - * @var string[] + * @var string[]|null */ protected $queryBuilders; @@ -77,6 +76,6 @@ public function getQueryBuilders(): array $this->loadFromConfig(); } - return $this->queryBuilders; + return $this->queryBuilders ?? []; } } diff --git a/src/Apitizer/Rendering/BasicRenderer.php b/src/Apitizer/Rendering/BasicRenderer.php index fc8fb04..831214d 100644 --- a/src/Apitizer/Rendering/BasicRenderer.php +++ b/src/Apitizer/Rendering/BasicRenderer.php @@ -4,6 +4,8 @@ use Apitizer\Policies\PolicyFailed; use Apitizer\QueryBuilder; +use Apitizer\Types\AbstractField; +use Apitizer\Types\Association; use Illuminate\Support\Arr; class BasicRenderer implements Renderer @@ -28,6 +30,12 @@ public function render(QueryBuilder $queryBuilder, $data, array $selectedFields) return $result; } + /** + * @param mixed $row + * @param (AbstractField|Association)[] $selectedFields + * + * @return array + */ protected function renderOne($row, array $selectedFields): array { $acc = []; @@ -49,6 +57,8 @@ protected function renderOne($row, array $selectedFields): array /** * Check if we're dealing with a single row of data or a collection of rows. + * + * @param array|object|iterable|mixed $data */ protected function isSingleRowOfData($data): bool { diff --git a/src/Apitizer/Rendering/Renderer.php b/src/Apitizer/Rendering/Renderer.php index 49b4526..d925034 100644 --- a/src/Apitizer/Rendering/Renderer.php +++ b/src/Apitizer/Rendering/Renderer.php @@ -4,7 +4,7 @@ use Apitizer\QueryBuilder; use Apitizer\Types\Association; -use Apitizer\Types\Field; +use Apitizer\Types\AbstractField; /** * Describes a class that can render data for the query builder. @@ -15,9 +15,9 @@ interface Renderer * Render data that was fetched according to the fetch specification. * * @param QueryBuilder $queryBuilder - * @param array|Collection|object|iterable $data - * @param (Field|Association)[] $selectedFields - * @return array + * @param array|Collection|object|iterable $data + * @param (AbstractField|Association)[] $selectedFields + * @return array|array> */ public function render(QueryBuilder $queryBuilder, $data, array $selectedFields): array; } diff --git a/src/Apitizer/ServiceProvider.php b/src/Apitizer/ServiceProvider.php index 849f12d..c5392db 100644 --- a/src/Apitizer/ServiceProvider.php +++ b/src/Apitizer/ServiceProvider.php @@ -45,6 +45,7 @@ public function boot() } $this->loadViewsFrom($root . '/resources/views', 'apitizer'); + $this->loadTranslationsFrom($root . '/resources/lang', 'apitizer'); if ($this->app->runningInConsole()) { $this->commands([ @@ -53,7 +54,7 @@ public function boot() } } - protected function registerRoutes() + protected function registerRoutes(): void { $routeConfig = [ 'namespace' => 'Apitizer\Controllers', diff --git a/src/Apitizer/Sorting/ColumnSort.php b/src/Apitizer/Sorting/ColumnSort.php index cf1a135..18d64aa 100644 --- a/src/Apitizer/Sorting/ColumnSort.php +++ b/src/Apitizer/Sorting/ColumnSort.php @@ -17,7 +17,7 @@ public function __construct(string $column) $this->column = $column; } - public function __invoke(Builder $query, Sort $sort) + public function __invoke(Builder $query, Sort $sort): void { $query->orderBy($this->column, $sort->getOrder()); } diff --git a/src/Apitizer/Support/ClassFilter.php b/src/Apitizer/Support/ClassFilter.php index e17824d..25e57ef 100644 --- a/src/Apitizer/Support/ClassFilter.php +++ b/src/Apitizer/Support/ClassFilter.php @@ -24,11 +24,16 @@ class ClassFilter extends FilterIterator protected $class; /** - * @var string the namespace to the current file we're handling. This will + * @var class-string the namespace to the current file we're handling. This will * be the return value of it passes the accept function. */ protected $current; + /** + * @param string $namespace + * @param string $class + * @param Iterator $iterator + */ public function __construct(string $namespace, string $class, Iterator $iterator) { parent::__construct($iterator); @@ -36,11 +41,17 @@ public function __construct(string $namespace, string $class, Iterator $iterator $this->class = $class; } + /** + * @return string + */ public function current() { return $this->current; } + /** + * @return bool + */ public function accept() { $fileInfo = $this->getInnerIterator()->current(); diff --git a/src/Apitizer/Support/ComposerNamespaceClassFinder.php b/src/Apitizer/Support/ComposerNamespaceClassFinder.php index 36d189c..8bf0a99 100644 --- a/src/Apitizer/Support/ComposerNamespaceClassFinder.php +++ b/src/Apitizer/Support/ComposerNamespaceClassFinder.php @@ -8,6 +8,7 @@ use IteratorAggregate; use Illuminate\Support\Arr; use Illuminate\Support\Str; +use Iterator; use RecursiveDirectoryIterator; use RegexIterator; @@ -43,6 +44,9 @@ public function __construct(string $namespace, string $class) $this->instanceofClass = $class; } + /** + * @return ComposerNamespaceClassFinder + */ public static function make(string $namespace, string $class) { return new static($namespace, $class); @@ -50,6 +54,8 @@ public static function make(string $namespace, string $class) /** * Set the project root where the composer.json should be. + * + * @return ComposerNamespaceClassFinder */ public function startingFrom(?string $projectRoot): self { @@ -61,6 +67,8 @@ public function startingFrom(?string $projectRoot): self /** * Whether or not the search process should be done recursively over all * subdirectories. + * + * @return ComposerNamespaceClassFinder */ public function recursively(bool $recursively): self { @@ -73,6 +81,8 @@ public function recursively(bool $recursively): self * Get all the classes that satisfy the constraints. * * @throws ClassFinderException + * + * @return array */ public function all(): array { @@ -81,8 +91,10 @@ public function all(): array /** * @throws ClassFinderException + * + * @return Iterator */ - public function getIterator() + public function getIterator(): Iterator { $projectRoot = $this->projectRoot ?? $this->findProjectRoot(); $composerFile = "$projectRoot/composer.json"; @@ -91,7 +103,11 @@ public function getIterator() throw ClassFinderException::composerFileNotFound($composerFile); } - $composerContent = json_decode(file_get_contents($composerFile), true); + if (! $content = file_get_contents($composerFile)) { + throw ClassFinderException::composerFileNotFound($composerFile); + } + + $composerContent = json_decode($content, true); if (! $psr4 = Arr::get($composerContent, 'autoload.psr-4')) { throw ClassFinderException::psr4NotFound($composerFile); } @@ -120,7 +136,10 @@ public function getIterator() return new ArrayIterator([]); } - private function iteratorForPath(string $path) + /** + * @return Iterator + */ + private function iteratorForPath(string $path): Iterator { $directoryIterator = $this->recursive ? new RecursiveDirectoryIterator($path) @@ -147,6 +166,10 @@ private function findProjectRoot(): ?string // Start at this project's parent directory. $directory = realpath(__DIR__ . str_repeat(DIRECTORY_SEPARATOR . '..', 4)); + if (! $directory) { + return null; + } + // Check if this project is contained within a vendor directory. if (basename($directory) === 'vendor') { // If so, return the directory that contains the vendor dir, which diff --git a/src/Apitizer/Support/DefinitionHelper.php b/src/Apitizer/Support/DefinitionHelper.php index dc97bd4..2f24721 100644 --- a/src/Apitizer/Support/DefinitionHelper.php +++ b/src/Apitizer/Support/DefinitionHelper.php @@ -19,7 +19,10 @@ class DefinitionHelper * Validate that each field has a correct type, possibly assigning the any * type. * - * @return (Field|Association)[] + * @param QueryBuilder $queryBuilder + * @param array $fields + * + * @return (AbstractField|Association)[] */ static function validateFields(QueryBuilder $queryBuilder, array $fields): array { @@ -35,8 +38,11 @@ static function validateFields(QueryBuilder $queryBuilder, array $fields): array /** * @param QueryBuilder $queryBuilder * @param string $name - * @param Field|Association $field + * @param AbstractField|Association|mixed $field + * * @throws DefinitionException + * + * @return AbstractField|Association */ static function validateField(QueryBuilder $queryBuilder, string $name, $field) { @@ -76,6 +82,11 @@ private static function isValidAssociation( /** * Validate that each sort has the correct type. + * + * @param QueryBuilder $queryBuilder + * @param array $sorts + * + * @return array */ static function validateSorts(QueryBuilder $queryBuilder, array $sorts): array { @@ -86,17 +97,31 @@ static function validateSorts(QueryBuilder $queryBuilder, array $sorts): array return $sorts; } + /** + * @param QueryBuilder $queryBuilder + * @param string $name + * @param Sort|mixed $sort + */ static function validateSort(QueryBuilder $queryBuilder, string $name, $sort): void { if (! $sort instanceof Sort) { throw DefinitionException::sortDefinitionExpected($queryBuilder, $name, $sort); } + if (! $sort->getHandler()) { + throw DefinitionException::sortHandlerNotDefined($queryBuilder, $name); + } + $sort->setName($name); } /** * Validate that each filter has the correct type. + * + * @param QueryBuilder $queryBuilder + * @param array $filters + * + * @return array */ static function validateFilters(QueryBuilder $queryBuilder, array $filters): array { @@ -107,6 +132,13 @@ static function validateFilters(QueryBuilder $queryBuilder, array $filters): arr return $filters; } + /** + * @param QueryBuilder $queryBuilder + * @param string $name + * @param Filter|mixed $filter + * + * @throws DefinitionException + */ static function validateFilter(QueryBuilder $queryBuilder, string $name, $filter): void { if (! $filter instanceof Filter) { diff --git a/src/Apitizer/Support/SchemaValidator.php b/src/Apitizer/Support/SchemaValidator.php index 8009288..dbccd99 100644 --- a/src/Apitizer/Support/SchemaValidator.php +++ b/src/Apitizer/Support/SchemaValidator.php @@ -20,7 +20,8 @@ class SchemaValidator * * Requires the query builders to be defined in the config. * - * @param null|(string|QueryBuilder)[] the list of query builders to validate. + * @param null|(string|QueryBuilder)[] $queryBuilders the list of query + * builders to validate. */ public function validateAll(array $queryBuilders = null): self { diff --git a/src/Apitizer/Support/TsViewHelper.php b/src/Apitizer/Support/TsViewHelper.php new file mode 100644 index 0000000..3068d3d --- /dev/null +++ b/src/Apitizer/Support/TsViewHelper.php @@ -0,0 +1,55 @@ +getElementType(); + + if ($elementType) { + return static::printableType($elementType, $depth) . '[]'; + } + + return 'any[]'; + } + + return $field instanceof ObjectRules + ? static::printObject($field, $depth + 1) + : static::toTsType($field->getType()); + } + + public static function printObject(ObjectRules $object, int $depth): string + { + return view('apitizer::ts_object', ['builder' => $object, 'depth' => $depth]); + } + + public static function toTsType(?string $type): string + { + if (! $type) { + return 'any'; + } + + switch ($type) { + case 'date': + case 'datetime': + return 'Date'; + case 'integer': + return 'number'; + default: + return $type; + } + } +} diff --git a/src/Apitizer/Support/TypeCaster.php b/src/Apitizer/Support/TypeCaster.php index aa87720..c355ac9 100644 --- a/src/Apitizer/Support/TypeCaster.php +++ b/src/Apitizer/Support/TypeCaster.php @@ -10,6 +10,15 @@ class TypeCaster { + /** + * @param mixed $value + * @param string $type + * @param string|null $format + * + * @throws CastException + * + * @return bool|int|float|string|DateTimeInterface|mixed|null + */ public static function cast($value, string $type, ?string $format = null) { try { @@ -22,6 +31,15 @@ public static function cast($value, string $type, ?string $format = null) } } + /** + * @param mixed $value + * @param string $type + * @param string|null $format + * + * @throws CastException + * + * @return bool|int|float|string|DateTimeInterface|mixed|null + */ private static function doCast($value, $type, $format) { // Null values should evaluate to "false" in the boolean cast, @@ -54,7 +72,16 @@ private static function doCast($value, $type, $format) } } - private static function castToDate($value, $type, $format): ?DateTimeInterface + /** + * @param mixed $value + * @param string $type + * @param string $format + * + * @throws CastException + * + * @return DateTimeInterface + */ + private static function castToDate($value, $type, string $format): DateTimeInterface { if ($value instanceof DateTimeInterface) { return $value; @@ -69,7 +96,16 @@ private static function castToDate($value, $type, $format): ?DateTimeInterface throw new CastException($value, $type, $format); } - private static function castUuid($value, $type, $format) + /** + * @param mixed $value + * @param string $type + * @param string|null $format + * + * @throws CastException + * + * @return string + */ + private static function castUuid($value, $type, $format): string { if ($value instanceof Uuid) { return $value; diff --git a/src/Apitizer/Transformers/CastValue.php b/src/Apitizer/Transformers/CastValue.php index 34ddd5b..2143a4e 100644 --- a/src/Apitizer/Transformers/CastValue.php +++ b/src/Apitizer/Transformers/CastValue.php @@ -7,6 +7,9 @@ class CastValue { + /** + * @var string|null + */ protected $format; public function __construct(string $format = null) @@ -14,6 +17,13 @@ public function __construct(string $format = null) $this->$format = $format; } + /** + * @param mixed $value + * @param mixed $row + * @param AbstractField $field + * + * @return mixed + */ public function __invoke($value, $row, AbstractField $field) { return TypeCaster::cast($value, $field->getType(), $this->format); diff --git a/src/Apitizer/Transformers/DateTimeFormat.php b/src/Apitizer/Transformers/DateTimeFormat.php index 83f40f2..384becc 100644 --- a/src/Apitizer/Transformers/DateTimeFormat.php +++ b/src/Apitizer/Transformers/DateTimeFormat.php @@ -6,6 +6,9 @@ class DateTimeFormat { + /** + * @var string + */ protected $format; public function __construct(string $format = 'Y-m-d H:i:s') @@ -13,7 +16,7 @@ public function __construct(string $format = 'Y-m-d H:i:s') $this->format = $format; } - public function __invoke(DateTimeInterface $value) + public function __invoke(DateTimeInterface $value): string { return $value->format($this->format); } diff --git a/src/Apitizer/Types/AbstractField.php b/src/Apitizer/Types/AbstractField.php index d284324..4d3b390 100644 --- a/src/Apitizer/Types/AbstractField.php +++ b/src/Apitizer/Types/AbstractField.php @@ -7,6 +7,7 @@ use Apitizer\Exceptions\InvalidOutputException; use Apitizer\Policies\PolicyFailed; use Apitizer\QueryBuilder; +use Apitizer\Rendering\Renderer; use ArrayAccess; abstract class AbstractField extends Factory @@ -40,7 +41,7 @@ abstract class AbstractField extends Factory * rendering process. For fields that use the Eloquent model, this function * would get the value at some key from the model. * - * @param ArrayAccess|array|object $row + * @param ArrayAccess|array|object $row * * @return mixed the value that should be rendered. */ @@ -82,7 +83,7 @@ public function nullable(bool $isNullable = true): self /** * Render a row of data. * - * @param ArrayAccess|array|object $row + * @param ArrayAccess|array|object $row * * @throws InvalidOutputException if the value does not adhere to the * requirements set by the field. For example, if the field is not @@ -91,7 +92,7 @@ public function nullable(bool $isNullable = true): self * * @return mixed the transformed value. */ - public function render($row) + public function render($row, Renderer $renderer = null) { $value = $this->validateValue($this->getValue($row), $row); @@ -106,6 +107,7 @@ public function render($row) * Apply all the transformers in insertion order. * * @param mixed $value the value to transform. + * @param ArrayAccess|array|object $row * * @return mixed the transformed value */ diff --git a/src/Apitizer/Types/Apidoc.php b/src/Apitizer/Types/Apidoc.php index 82aa039..d15f038 100644 --- a/src/Apitizer/Types/Apidoc.php +++ b/src/Apitizer/Types/Apidoc.php @@ -27,12 +27,12 @@ class Apidoc protected $name; /** - * @var Field[] + * @var AbstractField[] */ protected $fields = []; /** - * @var Assocation[] + * @var Association[] */ protected $associations = []; @@ -89,32 +89,52 @@ public function setName(string $name): self return $this; } + /** + * @return AbstractField[] + */ public function getFields(): array { return $this->fields; } + /** + * @return Association[] + */ public function getAssociations(): array { return $this->associations; } + /** + * @return Sort[] + */ public function getSorts(): array { return $this->queryBuilder->getSorts(); } + /** + * @return Filter[] + */ public function getFilters(): array { return $this->queryBuilder->getFilters(); } + /** + * @return array + */ + public function getValidationBuilders(): array + { + return $this->queryBuilder->getRules()->getBuilders(); + } + public function getQueryBuilder(): QueryBuilder { return $this->queryBuilder; } - protected function guessQueryBuilderResourceName() + protected function guessQueryBuilderResourceName(): string { // It might be better to guess based on the model's name. $className = (new ReflectionClass($this->queryBuilder))->getShortName(); @@ -140,6 +160,11 @@ public function hasSorts(): bool return ! empty($this->getSorts()); } + public function hasRules(): bool + { + return $this->getQueryBuilder()->getRules()->hasRules(); + } + public function hasAssociations(): bool { return ! empty($this->getAssociations()); @@ -168,6 +193,8 @@ public function getMetadata() * The metadata is a free form variable that can be filled with anything. If * you want to extend the documentation with your own metadata, this would * be the first place to look. + * + * @param mixed $data */ public function setMetadata($data): self { @@ -175,4 +202,16 @@ public function setMetadata($data): self return $this; } + + public function humanizeActionName(string $actionName): string + { + switch ($actionName) { + case 'store': + return 'Create'; + case 'destroy': + return 'Delete'; + default: + return Str::title($actionName); + } + } } diff --git a/src/Apitizer/Types/ApidocCollection.php b/src/Apitizer/Types/ApidocCollection.php index 2192d3b..f12e8b0 100644 --- a/src/Apitizer/Types/ApidocCollection.php +++ b/src/Apitizer/Types/ApidocCollection.php @@ -8,6 +8,8 @@ class ApidocCollection extends Collection { /** * @param string[] $builders + * + * @return ApidocCollection */ public static function forQueryBuilders(array $builders): ApidocCollection { @@ -26,15 +28,4 @@ public function findAssociationType(Association $assoc): ?Apidoc return $this->items[$builder] ?? null; } - - /** - * @return \ArrayIterator|Apidoc[] - */ - public function getIterator() - { - // Only override this for IDE type-hinting. - // See as example https://github.com/symfony/symfony/issues/16965 - return parent::getIterator(); - } - } diff --git a/src/Apitizer/Types/Association.php b/src/Apitizer/Types/Association.php index 72848ff..463dcf1 100644 --- a/src/Apitizer/Types/Association.php +++ b/src/Apitizer/Types/Association.php @@ -20,7 +20,8 @@ class Association extends Factory protected $key; /** - * @var null|array The fields to render on the related query builder. + * @var null|(AbstractField|Association)[] The fields to render + * on the related query builder. */ protected $fields; @@ -39,6 +40,12 @@ public function __construct( $this->key = $key; } + /** + * @param mixed $row + * @param Renderer $renderer + * + * @return mixed|PolicyFailed + */ public function render($row, Renderer $renderer) { $assocData = $this->valueFromRow($row, $this->getKey()); @@ -56,15 +63,21 @@ public function render($row, Renderer $renderer) } return $renderer->render( - $this->getRelatedQueryBuilder(), $assocData, $this->fields + $this->getRelatedQueryBuilder(), $assocData, $this->fields ?? [] ); } + /** + * @return (AbstractField|Association)[] + */ public function getFields(): ?array { return $this->fields; } + /** + * @param (AbstractField|Association)[] $fields + */ public function setFields(array $fields): self { $this->fields = $fields; diff --git a/src/Apitizer/Types/Concerns/FetchesValueFromRow.php b/src/Apitizer/Types/Concerns/FetchesValueFromRow.php index cc70081..0113d85 100644 --- a/src/Apitizer/Types/Concerns/FetchesValueFromRow.php +++ b/src/Apitizer/Types/Concerns/FetchesValueFromRow.php @@ -3,9 +3,16 @@ namespace Apitizer\Types\Concerns; use ArrayAccess; +use Illuminate\Database\Eloquent\Model; trait FetchesValueFromRow { + /** + * @param array|Model|mixed $row + * @param string $key + * + * @return mixed + */ protected function valueFromRow($row, string $key) { $value = null; diff --git a/src/Apitizer/Types/Concerns/HasPolicy.php b/src/Apitizer/Types/Concerns/HasPolicy.php index c41412d..254e5ff 100644 --- a/src/Apitizer/Types/Concerns/HasPolicy.php +++ b/src/Apitizer/Types/Concerns/HasPolicy.php @@ -4,6 +4,9 @@ use Apitizer\Policies\AnyPolicy; use Apitizer\Policies\Policy; +use Apitizer\Types\AbstractField; +use Apitizer\Types\Association; +use Illuminate\Database\Eloquent\Model; trait HasPolicy { @@ -51,6 +54,10 @@ public function policyAny(Policy ...$policies): self /** * Check if the current field/value passes the policies. + * + * @param mixed $value + * @param array|Model|mixed $row + * @param AbstractField|Association $fieldOrAssoc */ protected function passesPolicy($value, $row, $fieldOrAssoc): bool { diff --git a/src/Apitizer/Types/EnumField.php b/src/Apitizer/Types/EnumField.php index cdc2c16..8a4bfd3 100644 --- a/src/Apitizer/Types/EnumField.php +++ b/src/Apitizer/Types/EnumField.php @@ -14,10 +14,16 @@ class EnumField extends Field { /** - * @var array + * @var array */ protected $enum = []; + /** + * @param QueryBuilder $queryBuilder + * @param string $key + * @param array $enum + * @param string $type + */ public function __construct( QueryBuilder $queryBuilder, string $key, @@ -44,6 +50,9 @@ public function printType(): string return $this->typeOrNull("enum of {$this->type}"); } + /** + * @return array + */ public function getEnum(): array { return $this->enum; diff --git a/src/Apitizer/Types/FetchSpec.php b/src/Apitizer/Types/FetchSpec.php index 612ce16..66d4fdf 100644 --- a/src/Apitizer/Types/FetchSpec.php +++ b/src/Apitizer/Types/FetchSpec.php @@ -2,7 +2,7 @@ namespace Apitizer\Types; -use Apitizer\Types\Field; +use Apitizer\Types\AbstractField; use Apitizer\Types\Association; use Apitizer\Types\Sort; use Apitizer\Types\Filter; @@ -17,7 +17,7 @@ class FetchSpec /** * The fields that should be fetched. * - * @var (Field|Association)[] + * @var (AbstractField|Association)[] */ protected $fields = []; @@ -35,6 +35,11 @@ class FetchSpec */ protected $filters = []; + /** + * @param (AbstractField|Association)[] $fields + * @param Sort[] $sorts + * @param Filter[] $filters + */ public function __construct(array $fields = [], array $sorts = [], array $filters = []) { $this->fields = $fields; @@ -43,7 +48,7 @@ public function __construct(array $fields = [], array $sorts = [], array $filter } /** - * @return (Field|Association)[] + * @return (AbstractField|Association)[] */ public function getFields(): array { diff --git a/src/Apitizer/Types/Field.php b/src/Apitizer/Types/Field.php index a2ce1b8..789d3ab 100644 --- a/src/Apitizer/Types/Field.php +++ b/src/Apitizer/Types/Field.php @@ -24,6 +24,11 @@ public function __construct( $this->type = $type; } + /** + * @param mixed $row + * + * @return mixed + */ protected function getValue($row) { return $this->valueFromRow($row, $this->getKey()); diff --git a/src/Apitizer/Types/Filter.php b/src/Apitizer/Types/Filter.php index e92e294..9ea475f 100644 --- a/src/Apitizer/Types/Filter.php +++ b/src/Apitizer/Types/Filter.php @@ -19,7 +19,7 @@ class Filter extends Factory protected $type = 'string'; /** - * @var string the format for date(time) types. + * @var string|null the format for date(time) types. */ protected $format = null; @@ -147,7 +147,7 @@ public function byAssociation(string $relation, string $key = null): self * When this is method is used, expectMany cannot be used and a string will * automatically be expected. * - * @param array|string $fields + * @param string[]|string $fields * * @return self */ @@ -163,10 +163,12 @@ public function search($fields): self /** * Get the validated, type casted input. * + * @param mixed $input + * * @throws InvalidInputException * @throws CastException * - * @return array|mixed + * @return array|string|int|float|\DateTimeInterface|bool|mixed */ protected function validateInput($input) { @@ -192,11 +194,17 @@ public function getHandler(): ?callable return $this->handler; } + /** + * @return mixed + */ public function getValue() { return $this->value; } + /** + * @param mixed $value + */ public function setValue($value): self { try { diff --git a/src/Apitizer/Types/GeneratedField.php b/src/Apitizer/Types/GeneratedField.php index ef591da..5a25672 100644 --- a/src/Apitizer/Types/GeneratedField.php +++ b/src/Apitizer/Types/GeneratedField.php @@ -3,19 +3,20 @@ namespace Apitizer\Types; use Apitizer\QueryBuilder; +use Illuminate\Database\Eloquent\Model; class GeneratedField extends AbstractField { /** - * @param callable generator. + * @var callable generator. */ protected $generator; /** * @param QueryBuilder $queryBuilder * @param string $type - * @param $callable the callable that will generate the return value. This - * callable will receive two parameters: + * @param callable $generator the callable that will generate the return + * value. This callable will receive two parameters: * 1. The current row that is being rendered. * 2. The GeneratedField instance (this object). */ @@ -26,6 +27,11 @@ public function __construct(QueryBuilder $queryBuilder, string $type, callable $ $this->generator = $generator; } + /** + * @param array|Model|mixed $row + * + * @return mixed + */ protected function getValue($row) { return call_user_func($this->generator, $row, $this); diff --git a/src/Apitizer/Types/Sort.php b/src/Apitizer/Types/Sort.php index e354c08..31088e9 100644 --- a/src/Apitizer/Types/Sort.php +++ b/src/Apitizer/Types/Sort.php @@ -13,7 +13,7 @@ class Sort extends Factory protected $order = 'asc'; /** - * @var callable + * @var callable|null */ protected $handler; @@ -29,7 +29,7 @@ public function byField(string $field): self return $this; } - public function getOrder() + public function getOrder(): string { return $this->order; } @@ -41,7 +41,7 @@ public function setOrder(string $order): self return $this; } - public function getHandler() + public function getHandler(): ?callable { return $this->handler; } diff --git a/src/Apitizer/Validation/ArrayRules.php b/src/Apitizer/Validation/ArrayRules.php new file mode 100644 index 0000000..de28ebb --- /dev/null +++ b/src/Apitizer/Validation/ArrayRules.php @@ -0,0 +1,73 @@ +addConstraint('distinct'); + } + + /** + * Specify the type for the values of the array. + * + * Any rules for the array field specifically needs to be defined before this + * is called. + */ + public function whereEach(): ArrayTypePicker + { + return new ArrayTypePicker($this); + } + + /** + * @internal + */ + public function setElementType(TypedRuleBuilder $elementType): void + { + $elementType->setPrefix($this->getRulePrefix()); + + $this->elementType = $elementType; + } + + public function getElementType(): ?TypedRuleBuilder + { + return $this->elementType; + } + + public function getType(): string + { + return $this->elementType + ? 'array of ' . $this->elementType->getType() + : 'array'; + } + + public function getValidatableType() + { + return 'array'; + } + + public function getChildren(): array + { + return $this->elementType ? [$this->elementType] : []; + } + + public function getRulePrefix(): string + { + return $this->getValidationRuleName() . '.*'; + } + + public function resolve(): void + { + if ($this->elementType instanceof ContainerType) { + $this->elementType->resolve(); + } + } +} diff --git a/src/Apitizer/Validation/ArrayTypePicker.php b/src/Apitizer/Validation/ArrayTypePicker.php new file mode 100644 index 0000000..e39fd38 --- /dev/null +++ b/src/Apitizer/Validation/ArrayTypePicker.php @@ -0,0 +1,114 @@ +origin = $origin; + } + + public function string(): StringRules + { + $type = new StringRules(null); + + $this->setElementType($type); + + return $type; + } + + public function uuid(): StringRules + { + return $this->string()->uuid(); + } + + public function boolean(): BooleanRules + { + $type = new BooleanRules(null); + + $this->setElementType($type); + + return $type; + } + + public function date(string $format = null): DateRules + { + $type = DateRules::date(null, $format); + + $this->setElementType($type); + + return $type; + } + + public function datetime(string $format = null): DateRules + { + $type = DateRules::datetime(null, $format); + + $this->setElementType($type); + + return $type; + } + + public function number(): NumberRules + { + $type = new NumberRules(null); + + $this->setElementType($type); + + return $type; + } + + public function integer(): NumberRules + { + $type = new IntegerRules(null); + + $this->setElementType($type); + + return $type; + } + + public function file(): FileRules + { + $type = new FileRules(null); + + $this->setElementType($type); + + return $type; + } + + public function image(): FileRules + { + return $this->file()->image(); + } + + public function array(): ArrayRules + { + $type = new ArrayRules(null); + + $this->setElementType($type); + + return $type; + } + + public function object(Closure $callback): ObjectRules + { + $type = new ObjectRules(null, $callback); + + $this->setElementType($type); + + return $type; + } + + private function setElementType(TypedRuleBuilder $type): void + { + $this->origin->setElementType($type); + } +} diff --git a/src/Apitizer/Validation/BooleanRules.php b/src/Apitizer/Validation/BooleanRules.php new file mode 100644 index 0000000..67de32d --- /dev/null +++ b/src/Apitizer/Validation/BooleanRules.php @@ -0,0 +1,18 @@ +addConstraint('accepted'); + } + + public function getType(): string + { + return 'boolean'; + } +} diff --git a/src/Apitizer/Validation/Concerns/SharedRules.php b/src/Apitizer/Validation/Concerns/SharedRules.php new file mode 100644 index 0000000..f8e8f10 --- /dev/null +++ b/src/Apitizer/Validation/Concerns/SharedRules.php @@ -0,0 +1,230 @@ +required = true; + + return $this; + } + + public function requiredIf(RequiredIfRule $rule): self + { + return $this->addRule($rule); + } + + /** + * @param string[] $fields + */ + public function requiredWith(array $fields): self + { + return $this->addRule(new RequiredWithRule($fields)); + } + + /** + * @param string[] $fields + */ + public function requiredWithAll(array $fields): self + { + return $this->addRule(new RequiredWithAllRule($fields)); + } + + /** + * @param string[] $fields + */ + public function requiredWithout(array $fields): self + { + return $this->addRule(new RequiredWithoutRule($fields)); + } + + /** + * @param string[] $fields + */ + public function requiredWithoutAll(array $fields): self + { + return $this->addRule(new RequiredWithoutAllRule($fields)); + } + + public function nullable(): self + { + $this->nullable = true; + + return $this; + } + + /** + * @param int|float $size + */ + public function size($size): self + { + return $this->addRule(new SizeRule($size, $this->getType())); + } + + /** + * @param int|float $size + */ + public function max($size): self + { + return $this->addRule(new MaxRule($size, $this->getType())); + } + + /** + * @param int|float $size + */ + public function min($size): self + { + return $this->addRule(new MinRule($size, $this->getType())); + } + + public function bail(): self + { + // Doesn't need to show up in the documentation. + return $this->addRule('bail'); + } + + public function confirmed(): self + { + return $this->addRule(new ConfirmedRule($this->getFieldName() ?? '')); + } + + public function between(int $min, int $max): self + { + return $this->addRule(new BetweenRule($min, $max)); + } + + public function different(string $field): self + { + return $this->addRule(new DifferentRule($field)); + } + + public function same(string $field): self + { + return $this->addRule(new SameRule($field)); + } + + public function regex(string $regex): self + { + return $this->addRule(new RegexRule($regex)); + } + + public function notRegex(string $regex): self + { + return $this->addRule(new NotRegexRule($regex)); + } + + public function gt(string $field): self + { + return $this->addRule(new GtRule($field)); + } + + public function gte(string $field): self + { + return $this->addRule(new GteRule($field)); + } + + public function lt(string $field): self + { + return $this->addRule(new LtRule($field)); + } + + public function lte(string $field): self + { + return $this->addRule(new LteRule($field)); + } + + /** + * @param (string|mixed)[] $values + */ + public function in(array $values): self + { + return $this->addRule(new InRule($values)); + } + + /** + * @param (string|mixed)[] $values + */ + public function notIn(array $values): self + { + return $this->addRule(new NotInRule($values)); + } + + public function inArray(string $field): self + { + return $this->addRule(new InArrayRule($field)); + } + + public function filled(): self + { + return $this->addConstraint('filled'); + } + + public function exists(string $table, string $column = 'NULL'): self + { + return $this->addRule(new ExistsRule($table, $column)); + } + + public function unique(string $table, string $column = 'NULL'): self + { + return $this->addRule(new UniqueRule($table, $column)); + } + + public function sometimes(): self + { + return $this->addConstraint('sometimes'); + } + + public function present(): self + { + return $this->addConstraint('present'); + } + + /** + * @param Rule|string|ValidationRule $rule + */ + public function addRule($rule): self + { + $this->rules[] = $rule; + + return $this; + } + + protected function addConstraint(string $name): self + { + return $this->addRule(new Constraint($name)); + } +} diff --git a/src/Apitizer/Validation/ContainerType.php b/src/Apitizer/Validation/ContainerType.php new file mode 100644 index 0000000..8f98832 --- /dev/null +++ b/src/Apitizer/Validation/ContainerType.php @@ -0,0 +1,16 @@ +format = $format; + $this->type = $type; + } + + public static function date(?string $fieldName, string $format = null): DateRules + { + return new static($fieldName, $format ?? config('apitizer.validation.date_format')); + } + + public static function datetime(?string $fieldName, string $format = null): DateRules + { + return new static( + $fieldName, + $format ?? config('apitizer.validation.datetime_format'), + 'datetime' + ); + } + + public function after(DateTimeInterface $date): self + { + return $this->addRule(new AfterRule($date, $this->format)); + } + + public function afterOrEqual(DateTimeInterface $date): self + { + return $this->addRule(new AfterOrEqualRule($date, $this->format)); + } + + public function before(DateTimeInterface $date): self + { + return $this->addRule(new BeforeRule($date, $this->format)); + } + + public function beforeOrEqual(DateTimeInterface $date): self + { + return $this->addRule(new BeforeOrEqualRule($date, $this->format)); + } + + public function equals(DateTimeInterface $date): self + { + return $this->addRule(new DateEqualsRule($date, $this->format)); + } + + public function getType(): string + { + return $this->type; + } + + public function getValidatableType() + { + return $this->getType() === 'date' + ? 'date' + : (new DateFormatRule($this->format))->toValidationRule(); + } +} diff --git a/src/Apitizer/Validation/FieldRuleBuilder.php b/src/Apitizer/Validation/FieldRuleBuilder.php new file mode 100644 index 0000000..73ea8a5 --- /dev/null +++ b/src/Apitizer/Validation/FieldRuleBuilder.php @@ -0,0 +1,90 @@ +fieldName = $fieldName; + } + + /** + * Get the type of the current field. + */ + abstract public function getType(): string; + + /** + * Get the name of the current field. + */ + public function getFieldName(): ?string + { + return $this->fieldName; + } + + public function getRules(): array + { + return $this->rules; + } + + public function isRequired(): bool + { + return $this->required; + } + + public function isNullable(): bool + { + return $this->nullable; + } + + /** + * @internal + * + * @return string|null|Rule + */ + public function getValidatableType() + { + return $this->getType(); + } + + /** + * @internal + */ + public function setPrefix(string $prefix): void + { + $this->prefix = $prefix; + } + + public function getValidationRuleName(): string + { + return $this->prefix . $this->getFieldName(); + } +} diff --git a/src/Apitizer/Validation/FileRules.php b/src/Apitizer/Validation/FileRules.php new file mode 100644 index 0000000..9f15744 --- /dev/null +++ b/src/Apitizer/Validation/FileRules.php @@ -0,0 +1,43 @@ +addConstraint('image'); + } + + public function dimensions(DimensionsRule $rule): self + { + return $this->addRule($rule); + } + + /** + * @param string[] $mimetypes + */ + public function mimetypes(array $mimetypes): self + { + return $this->addRule(new MimetypesRule($mimetypes)); + } + + /** + * @param string[] $mimes + */ + public function mimes(array $mimes): self + { + return $this->addRule(new MimesRule($mimes)); + } + + public function getType(): string + { + return 'file'; + } +} diff --git a/src/Apitizer/Validation/IntegerRules.php b/src/Apitizer/Validation/IntegerRules.php new file mode 100644 index 0000000..50f7c28 --- /dev/null +++ b/src/Apitizer/Validation/IntegerRules.php @@ -0,0 +1,16 @@ +getType(); + } +} diff --git a/src/Apitizer/Validation/NumberRules.php b/src/Apitizer/Validation/NumberRules.php new file mode 100644 index 0000000..b0ea92a --- /dev/null +++ b/src/Apitizer/Validation/NumberRules.php @@ -0,0 +1,52 @@ +addRule(new DigitsRule($length)); + } + + public function digitsBetween(int $min, int $max): self + { + return $this->addRule(new DigitsBetweenRule($min, $max)); + } + + /** + * @param string[] $values + */ + public function endsWith(array $values): self + { + return $this->addRule(new EndsWithRule($values)); + } + + /** + * @param string[] $values + */ + public function startsWith(array $values): self + { + return $this->addRule(new StartsWithRule($values)); + } + + public function getType(): string + { + return 'number'; + } + + /** + * @return string + */ + public function getValidatableType() + { + return 'numeric'; + } +} diff --git a/src/Apitizer/Validation/ObjectRules.php b/src/Apitizer/Validation/ObjectRules.php new file mode 100644 index 0000000..c28ef11 --- /dev/null +++ b/src/Apitizer/Validation/ObjectRules.php @@ -0,0 +1,178 @@ + + */ + protected $fields = []; + + /** + * @var string|null + */ + protected $fieldName; + + /** + * @var Closure + */ + protected $callback; + + public function __construct(?string $fieldName, Closure $callback) + { + parent::__construct($fieldName); + $this->callback = $callback; + } + + public function string(string $name): StringRules + { + $stringRules = new StringRules($name); + + $this->addField($stringRules); + + return $stringRules; + } + + public function uuid(string $name): StringRules + { + return $this->string($name)->uuid(); + } + + public function boolean(string $name): BooleanRules + { + $booleanRules = new BooleanRules($name); + + $this->addField($booleanRules); + + return $booleanRules; + } + + public function date(string $name, string $format = null): DateRules + { + $dateRules = DateRules::date($name, $format); + + $this->addField($dateRules); + + return $dateRules; + } + + public function datetime(string $name, string $format = null): DateRules + { + $dateRules = DateRules::datetime($name, $format); + + $this->addField($dateRules); + + return $dateRules; + } + + public function number(string $name): NumberRules + { + $numberRules = new NumberRules($name); + + $this->addField($numberRules); + + return $numberRules; + } + + public function integer(string $name): IntegerRules + { + $integerRules = new IntegerRules($name); + + $this->addField($integerRules); + + return $integerRules; + } + + public function file(string $name): FileRules + { + $fileRules = new FileRules($name); + + $this->addField($fileRules); + + return $fileRules; + } + + public function image(string $name): FileRules + { + return $this->file($name)->image(); + } + + public function object(string $name, Closure $callback): ObjectRules + { + $objectRules = new ObjectRules($name, $callback); + + $this->addField($objectRules); + + return $objectRules; + } + + public function array(string $name): ArrayRules + { + $arrayRules = new ArrayRules($name); + + $this->addField($arrayRules); + + return $arrayRules; + } + + private function addField(TypedRuleBuilder $type): void + { + $type->setPrefix($this->getRulePrefix()); + + // TODO Add exception when redefining the same field. + $this->fields[$type->getFieldName()] = $type; + } + + public function getFieldName(): ?string + { + return $this->fieldName; + } + + public function getType(): string + { + return 'object'; + } + + /** + * @return null + */ + public function getValidatableType() + { + return null; + } + + /** + * @return TypedRuleBuilder[] + */ + public function getChildren(): array + { + return $this->fields; + } + + public function getRulePrefix(): string + { + if (empty($this->getValidationRuleName())) { + return ''; + } + + return $this->getValidationRuleName() . '.'; + } + + public function resolve(): void + { + ($this->callback)($this); + + // Also resolve all the nested objects. + foreach ($this->getChildren() as $field) { + if ($field instanceof ContainerType) { + $field->resolve(); + } + } + } +} diff --git a/src/Apitizer/Validation/RuleInterpreter.php b/src/Apitizer/Validation/RuleInterpreter.php new file mode 100644 index 0000000..1504909 --- /dev/null +++ b/src/Apitizer/Validation/RuleInterpreter.php @@ -0,0 +1,77 @@ + + */ + public static function rulesFrom(TypedRuleBuilder $builder): array + { + $rules = []; + static::makeRules($rules, $builder); + + return $rules; + } + + /** + * @param array $rules + * @param TypedRuleBuilder $builder + * + * @return void + */ + protected static function makeRules(array &$rules, TypedRuleBuilder $builder): void + { + $path = $builder->getValidationRuleName(); + + if (! empty($path)) { + // Render the rules for this builder. + $rules[$path] = static::toValidationRules($builder); + } + + if ($builder instanceof ContainerType) { + foreach ($builder->getChildren() as $childBuilder) { + static::makeRules($rules, $childBuilder); + } + } + } + + /** + * @param TypedRuleBuilder $builder + * + * @return (string|Rule)[] + */ + protected static function toValidationRules(TypedRuleBuilder $builder): array + { + $rules = []; + + if ($builder->isRequired()) { + $rules[] = 'required'; + } + + if ($type = $builder->getValidatableType()) { + $rules[] = $type; + } + + if ($builder->isNullable()) { + $rules[] = 'nullable'; + } + + foreach ($builder->getRules() as $rule) { + if ($rule instanceof ValidationRule) { + $rules[] = $rule->toValidationRule(); + } + + if (is_string($rule) || $rule instanceof Rule) { + $rules[] = $rule; + } + } + + return $rules; + } +} diff --git a/src/Apitizer/Validation/Rules.php b/src/Apitizer/Validation/Rules.php new file mode 100644 index 0000000..31dcce9 --- /dev/null +++ b/src/Apitizer/Validation/Rules.php @@ -0,0 +1,139 @@ +rules[$routeAction] = $callback; + + return $this; + } + + /** + * Shorthand for defining rules for the store method. + * + * @see Rules::define + */ + public function storeRules(Closure $callback): self + { + return $this->define('store', $callback); + } + + /** + * Shorthand for defining rules for the update method. + * + * @see Rules::define + */ + public function updateRules(Closure $callback): self + { + return $this->define('update', $callback); + } + + /** + * Get all the validation rule builders. + * + * @return array + */ + public function getBuilders(): array + { + return collect($this->rules)->map(function ($rules, string $actionMethod) { + return $this->resolveRulesFor($actionMethod); + })->all(); + } + + /** + * Get the validation rule builder for a specific action method. + * + * @return ObjectRules + */ + public function getBuilder(string $actionMethod): ObjectRules + { + return $this->resolveRulesFor($actionMethod); + } + + /** + * Get the validation rules for all the action methods. + * + * @return array> + */ + public function getValidationRules(): array + { + return collect($this->getBuilders())->map(function (ObjectRules $builder) { + return RuleInterpreter::rulesFrom($builder); + })->all(); + } + + /** + * Get the validation rules for a specific action. + * + * @return array + */ + public function getValidationRulesForAction(string $actionMethod): array + { + return RuleInterpreter::rulesFrom($this->getBuilder($actionMethod)); + } + + /** + * Check if any rules have been defined. + */ + public function hasRules(): bool + { + return !empty($this->rules); + } + + /** + * Check if any rules have been defined for the given action method. + */ + public function hasRulesFor(string $actionMethod): bool + { + return isset($this->rules[$actionMethod]); + } + + /** + * Resolve the rules for an action method from a closure to an actual list + * of validation rules. + * + * Resolving is only done on an as-needed basis to prevent needless objects + * from being created. + */ + protected function resolveRulesFor(string $actionMethod): ObjectRules + { + if (! $this->hasRulesFor($actionMethod)) { + return new ObjectRules(null, function () {}); + } + + $object = $this->rules[$actionMethod]; + + if ($object instanceof ObjectRules) { + return $object; + } + + // Resolve the closure and cache the results. + $object = (new ObjectRules(null, $object)); + $object->resolve(); + $this->rules[$actionMethod] = $object; + + return $object; + } +} diff --git a/src/Apitizer/Validation/Rules/AfterOrEqualRule.php b/src/Apitizer/Validation/Rules/AfterOrEqualRule.php new file mode 100644 index 0000000..f7355a8 --- /dev/null +++ b/src/Apitizer/Validation/Rules/AfterOrEqualRule.php @@ -0,0 +1,11 @@ +min = $min; + $this->max = $max; + } + + public function getName(): string + { + return 'between'; + } + + public function getParameters(): array + { + return [ + 'min' => $this->min, + 'max' => $this->max, + ]; + } + + public function getDocumentation(): ?string + { + return trans('apitizer::validation.between', $this->getParameters()); + } + + public function toValidationRule() + { + return $this->getName() . ':' . $this->min . ',' . $this->max; + } + + public function toHtml(): string + { + return trans('apitizer::validation.between', [ + 'min' => "{$this->min}", + 'max' => "{$this->max}", + ]); + } +} diff --git a/src/Apitizer/Validation/Rules/ConfirmedRule.php b/src/Apitizer/Validation/Rules/ConfirmedRule.php new file mode 100644 index 0000000..7901e29 --- /dev/null +++ b/src/Apitizer/Validation/Rules/ConfirmedRule.php @@ -0,0 +1,21 @@ +field = $field . '_confirmed'; + } + + public function getName(): string + { + return 'confirmed'; + } + + public function toValidationRule() + { + return $this->getName(); + } +} diff --git a/src/Apitizer/Validation/Rules/Constraint.php b/src/Apitizer/Validation/Rules/Constraint.php new file mode 100644 index 0000000..e33b516 --- /dev/null +++ b/src/Apitizer/Validation/Rules/Constraint.php @@ -0,0 +1,48 @@ +name = $name; + } + + public function getName(): string + { + return $this->name; + } + + public function getDocumentation(): ?string + { + return trans('apitizer::validation.'. $this->getName()); + } + + public function getParameters(): array + { + return []; + } + + public function toValidationRule() + { + return $this->name; + } + + public function __toString() + { + return $this->getDocumentation() ?? ''; + } + + public function toHtml(): string + { + return (string) $this; + } +} diff --git a/src/Apitizer/Validation/Rules/DateEqualsRule.php b/src/Apitizer/Validation/Rules/DateEqualsRule.php new file mode 100644 index 0000000..e5e61d3 --- /dev/null +++ b/src/Apitizer/Validation/Rules/DateEqualsRule.php @@ -0,0 +1,11 @@ +format = $format; + } + + public function getName(): string + { + return 'date_format'; + } + + public function getParameters(): array + { + return ['format' => $this->format]; + } + + public function getDocumentation(): ?string + { + return trans('apitizer::validation.date_format', ['format' => $this->format]); + } + + public function toValidationRule() + { + return $this->getName() . ":{$this->format}"; + } + + public function toHtml(): string + { + return trans('apitizer::validation.date_format', [ + 'format' => "{$this->format}" + ]); + } +} diff --git a/src/Apitizer/Validation/Rules/DateRule.php b/src/Apitizer/Validation/Rules/DateRule.php new file mode 100644 index 0000000..fb586c8 --- /dev/null +++ b/src/Apitizer/Validation/Rules/DateRule.php @@ -0,0 +1,58 @@ +date = $date; + $this->format = $format; + $this->formatted = $date->format($format); + } + + public function getParameters(): array + { + return [ + 'date' => $this->date, + 'format' => $this->format, + ]; + } + + public function getDocumentation(): ?string + { + return trans("apitizer::validation.{$this->getName()}", [ + 'date' => $this->formatted, + ]); + } + + public function toValidationRule() + { + return $this->getName() . ':' . $this->formatted; + } + + public function toHtml(): string + { + return trans("apitizer::validation.{$this->getName()}", [ + 'date' => "{$this->formatted}", + ]); + } +} diff --git a/src/Apitizer/Validation/Rules/DifferentRule.php b/src/Apitizer/Validation/Rules/DifferentRule.php new file mode 100644 index 0000000..ab18baf --- /dev/null +++ b/src/Apitizer/Validation/Rules/DifferentRule.php @@ -0,0 +1,11 @@ +min = $min; + $this->max = $max; + } + + public function getName(): string + { + return 'digits_between'; + } + + public function getParameters(): array + { + return [ + 'min' => $this->min, + 'max' => $this->max, + ]; + } + + public function getDocumentation(): ?string + { + return trans('apitizer::validation.digits_between', $this->getParameters()); + } + + public function toValidationRule() + { + return $this->getName() . ':' . $this->min . ',' . $this->max; + } + + public function toHtml(): string + { + return trans('apitizer::validation.digits_between', [ + 'min' => "{$this->min}", + 'max' => "{$this->max}", + ]); + } +} diff --git a/src/Apitizer/Validation/Rules/DigitsRule.php b/src/Apitizer/Validation/Rules/DigitsRule.php new file mode 100644 index 0000000..f04ade5 --- /dev/null +++ b/src/Apitizer/Validation/Rules/DigitsRule.php @@ -0,0 +1,43 @@ +digits = $digits; + } + + public function getName(): string + { + return 'digits'; + } + + public function getParameters(): array + { + return ['digits' => $this->digits]; + } + + public function getDocumentation(): ?string + { + return trans('apitizer::validation.digits', ['length' => $this->digits]); + } + + public function toValidationRule() + { + return $this->getName() . ':' . $this->digits; + } + + public function toHtml(): string + { + return $this->getDocumentation() ?? ''; + } +} diff --git a/src/Apitizer/Validation/Rules/DimensionsRule.php b/src/Apitizer/Validation/Rules/DimensionsRule.php new file mode 100644 index 0000000..4e2ae0f --- /dev/null +++ b/src/Apitizer/Validation/Rules/DimensionsRule.php @@ -0,0 +1,38 @@ +constraints; + } + + public function getDocumentation(): ?string + { + return trans('apitizer::validation.dimensions'); + } + + public function toValidationRule() + { + return $this; + } + + public function toHtml(): string + { + $list = collect($this->constraints)->map(function ($constraint, $key) { + return "
  • $key: $constraint
  • "; + })->implode("\n"); + + return trans('apitizer::validation.dimensions') . "
      $list
    "; + } +} diff --git a/src/Apitizer/Validation/Rules/EmailRule.php b/src/Apitizer/Validation/Rules/EmailRule.php new file mode 100644 index 0000000..c9c13d5 --- /dev/null +++ b/src/Apitizer/Validation/Rules/EmailRule.php @@ -0,0 +1,46 @@ +styles = $styles; + } + + public function getName(): string + { + return 'email'; + } + + public function getParameters(): array + { + return ['styles' => $this->styles]; + } + + public function getDocumentation(): ?string + { + return trans('apitizer::validation.email'); + } + + public function toValidationRule() + { + return $this->getName() . ':' . implode(',', $this->styles); + } + + public function toHtml(): string + { + return $this->getDocumentation() ?? ''; + } +} diff --git a/src/Apitizer/Validation/Rules/EndsWithRule.php b/src/Apitizer/Validation/Rules/EndsWithRule.php new file mode 100644 index 0000000..0a93062 --- /dev/null +++ b/src/Apitizer/Validation/Rules/EndsWithRule.php @@ -0,0 +1,11 @@ + $this->table, + 'column' => $this->column, + ]; + } + + public function getDocumentation(): ?string + { + return trans('apitizer::validation.exists'); + } + + public function toValidationRule() + { + return $this; + } + + public function toHtml(): string + { + return $this->getDocumentation() ?? ''; + } +} diff --git a/src/Apitizer/Validation/Rules/FieldRule.php b/src/Apitizer/Validation/Rules/FieldRule.php new file mode 100644 index 0000000..a17ddb0 --- /dev/null +++ b/src/Apitizer/Validation/Rules/FieldRule.php @@ -0,0 +1,43 @@ +field = $field; + } + + public function getParameters(): array + { + return ['field' => $this->field]; + } + + public function getDocumentation(): ?string + { + return trans("apitizer::validation.{$this->field}", $this->getParameters()); + } + + public function toValidationRule() + { + return $this->getName() . ':' . $this->field; + } + + public function toHtml(): string + { + return trans("apitizer::validation.{$this->field}", [ + 'field' => "{$this->field}", + ]); + } +} diff --git a/src/Apitizer/Validation/Rules/GtRule.php b/src/Apitizer/Validation/Rules/GtRule.php new file mode 100644 index 0000000..1fe18eb --- /dev/null +++ b/src/Apitizer/Validation/Rules/GtRule.php @@ -0,0 +1,11 @@ +field = rtrim($field, '.*'); + } + + public function getName(): string + { + return 'in_array'; + } + + public function toValidationRule() + { + return $this->getName() . ':' . $this->field . '.*'; + } +} diff --git a/src/Apitizer/Validation/Rules/InRule.php b/src/Apitizer/Validation/Rules/InRule.php new file mode 100644 index 0000000..6f4fe17 --- /dev/null +++ b/src/Apitizer/Validation/Rules/InRule.php @@ -0,0 +1,40 @@ + $this->values, + ]; + } + + public function getDocumentation(): ?string + { + return trans("apitizer::validation.{$this->getName()}"); + } + + public function toValidationRule() + { + return (string) $this; + } + + public function toHtml(): string + { + $values = collect($this->values)->map(function ($value) { + return '' . $value . ''; + })->implode(', '); + + return $this->getDocumentation() . ': ' . $values; + } +} diff --git a/src/Apitizer/Validation/Rules/LtRule.php b/src/Apitizer/Validation/Rules/LtRule.php new file mode 100644 index 0000000..6f1e068 --- /dev/null +++ b/src/Apitizer/Validation/Rules/LtRule.php @@ -0,0 +1,11 @@ + $this->size]; + } + + public function getDocumentation(): ?string + { + return trans('apitizer::validation.max', [ + 'max' => $this->suffixUnit($this->size), + ]); + } + + public function toHtml(): string + { + return trans('apitizer::validation.max', [ + 'max' => "{$this->suffixUnit($this->size)}" + ]); + } +} diff --git a/src/Apitizer/Validation/Rules/MimesRule.php b/src/Apitizer/Validation/Rules/MimesRule.php new file mode 100644 index 0000000..a4c0b54 --- /dev/null +++ b/src/Apitizer/Validation/Rules/MimesRule.php @@ -0,0 +1,50 @@ +mimes = $mimes; + } + + public function getName(): string + { + return 'mimes'; + } + + public function getParameters(): array + { + return ['mimes' => $this->mimes]; + } + + public function getDocumentation(): ?string + { + return trans('apitizer::validation.mimes'); + } + + public function toValidationRule() + { + return $this->getName() . ':' . implode(',', $this->mimes); + } + + public function toHtml(): string + { + $values = collect($this->mimes)->map(function ($value) { + return '' . $value . ''; + })->implode(', '); + + return $this->getDocumentation() . ': ' . $values; + } +} diff --git a/src/Apitizer/Validation/Rules/MimetypesRule.php b/src/Apitizer/Validation/Rules/MimetypesRule.php new file mode 100644 index 0000000..55bda75 --- /dev/null +++ b/src/Apitizer/Validation/Rules/MimetypesRule.php @@ -0,0 +1,50 @@ +mimetypes = $mimetypes; + } + + public function getName(): string + { + return 'mimetypes'; + } + + public function getParameters(): array + { + return ['mimetypes' => $this->mimetypes]; + } + + public function getDocumentation(): ?string + { + return trans('apitizer::validation.mimetypes'); + } + + public function toValidationRule() + { + return $this->getName() . ':' . implode(',', $this->mimetypes); + } + + public function toHtml(): string + { + $values = collect($this->mimetypes)->map(function ($value) { + return '' . $value . ''; + })->implode(', '); + + return $this->getDocumentation() . ': ' . $values; + } +} diff --git a/src/Apitizer/Validation/Rules/MinRule.php b/src/Apitizer/Validation/Rules/MinRule.php new file mode 100644 index 0000000..9ddbb03 --- /dev/null +++ b/src/Apitizer/Validation/Rules/MinRule.php @@ -0,0 +1,30 @@ + $this->size]; + } + + public function getDocumentation(): ?string + { + return trans('apitizer::validation.min', [ + 'min' => $this->suffixUnit($this->size), + ]); + } + + public function toHtml(): string + { + return trans('apitizer::validation.min', [ + 'min' => "{$this->suffixUnit($this->size)}" + ]); + } +} diff --git a/src/Apitizer/Validation/Rules/NotInRule.php b/src/Apitizer/Validation/Rules/NotInRule.php new file mode 100644 index 0000000..1aa9433 --- /dev/null +++ b/src/Apitizer/Validation/Rules/NotInRule.php @@ -0,0 +1,11 @@ +regex = trim($regex, '/'); + } + + public function getName(): string + { + return 'regex'; + } + + public function getParameters(): array + { + return ['regex' => $this->regex]; + } + + public function getDocumentation(): ?string + { + return trans("apitizer::validation.{$this->getName()}", $this->getParameters()); + } + + public function toValidationRule() + { + return $this->getName() . ':' . "/{$this->regex}/"; + } + + public function toHtml(): string + { + return trans("apitizer::validation.{$this->getName()}", [ + 'regex' => "{$this->regex}", + ]); + } +} diff --git a/src/Apitizer/Validation/Rules/RequiredIfRule.php b/src/Apitizer/Validation/Rules/RequiredIfRule.php new file mode 100644 index 0000000..4d8cc5f --- /dev/null +++ b/src/Apitizer/Validation/Rules/RequiredIfRule.php @@ -0,0 +1,49 @@ +condition = $condition; + $this->explanation = $explanation; + } + + public function getName(): string + { + return 'required_if'; + } + + public function getParameters(): array + { + return []; + } + + public function getDocumentation(): ?string + { + return trans('apitizer::validation.required_if', ['reason' => $this->explanation]); + } + + public function toValidationRule() + { + return $this; + } + + public function toHtml(): string + { + return $this->getDocumentation() ?? ''; + } +} diff --git a/src/Apitizer/Validation/Rules/RequiredWithAllRule.php b/src/Apitizer/Validation/Rules/RequiredWithAllRule.php new file mode 100644 index 0000000..2cddc20 --- /dev/null +++ b/src/Apitizer/Validation/Rules/RequiredWithAllRule.php @@ -0,0 +1,11 @@ +fields = $fields; + } + + public function getName(): string + { + return 'required_with'; + } + + public function getParameters(): array + { + return ['fields' => $this->fields]; + } + + public function getDocumentation(): ?string + { + return trans("apitizer::validation.{$this->getName()}"); + } + + public function toValidationRule() + { + return $this->getName() . ':' . implode(',', $this->fields); + } + + public function toHtml(): string + { + $values = collect($this->fields)->map(function ($field) { + return "$field"; + })->implode(', '); + + return $this->getDocumentation() . ': ' . $values; + } +} diff --git a/src/Apitizer/Validation/Rules/RequiredWithoutAllRule.php b/src/Apitizer/Validation/Rules/RequiredWithoutAllRule.php new file mode 100644 index 0000000..5fbd438 --- /dev/null +++ b/src/Apitizer/Validation/Rules/RequiredWithoutAllRule.php @@ -0,0 +1,11 @@ +size = $size; + $this->type = $type; + } + + public function getName(): string + { + return 'size'; + } + + public function getParameters(): array + { + return [ + 'size' => $this->size, + ]; + } + + public function getDocumentation(): ?string + { + return trans('apitizer::validation.size', [ + 'size' => $this->suffixUnit($this->size) + ]); + } + + public function toValidationRule() + { + return $this->getName() . ':' . $this->size; + } + + public function toHtml(): string + { + return trans('apitizer::validation.size', [ + 'size' => "{$this->suffixUnit($this->size)}" + ]); + } + + /** + * @param int|float $value + */ + protected function suffixUnit($value): string + { + $suffix = ''; + + switch ($this->type) { + case 'string': + $suffix = $value === 1 ? 'character' : 'characters'; + break; + case 'file': + $suffix = $value === 1 ? 'byte' : 'bytes'; + break; + case 'array': + $suffix = $value === 1 ? 'element' : 'elements'; + } + + $value = (string) $value; + + return empty($suffix) ? $value : "$value $suffix"; + } +} diff --git a/src/Apitizer/Validation/Rules/StartsWithRule.php b/src/Apitizer/Validation/Rules/StartsWithRule.php new file mode 100644 index 0000000..6f4e889 --- /dev/null +++ b/src/Apitizer/Validation/Rules/StartsWithRule.php @@ -0,0 +1,52 @@ +values = $values; + } + + public function getName(): string + { + return 'starts_with'; + } + + public function getParameters(): array + { + return [ + 'values' => $this->values, + ]; + } + + public function getDocumentation(): ?string + { + return trans("apitizer::validation.{$this->getName()}"); + } + + public function toValidationRule() + { + return $this->getName() . ':' . implode(',', $this->values); + } + + public function toHtml(): string + { + $values = collect($this->values)->map(function ($value) { + return '' . $value . ''; + })->implode(', '); + + return trans("apitizer::validation.{$this->getName()}") . ': ' . $values; + } +} diff --git a/src/Apitizer/Validation/Rules/UniqueRule.php b/src/Apitizer/Validation/Rules/UniqueRule.php new file mode 100644 index 0000000..b0d8d92 --- /dev/null +++ b/src/Apitizer/Validation/Rules/UniqueRule.php @@ -0,0 +1,37 @@ + $this->table, + 'column' => $this->column, + ]; + } + + public function getDocumentation(): ?string + { + return trans('apitizer::validation.unique'); + } + + public function toValidationRule() + { + return $this; + } + + public function toHtml(): string + { + return $this->getDocumentation() ?? ''; + } +} diff --git a/src/Apitizer/Validation/StringRules.php b/src/Apitizer/Validation/StringRules.php new file mode 100644 index 0000000..cdd1d1d --- /dev/null +++ b/src/Apitizer/Validation/StringRules.php @@ -0,0 +1,176 @@ +addConstraint('active_url'); + } + + /** + * Validate that the string consists only of alphabetic characters. + */ + public function alpha(): self + { + return $this->addConstraint('alpha'); + } + + /** + * Validate that the string consists only of alphanumeric characters, + * dashes, and underscores. + */ + public function alphaDash(): self + { + return $this->addConstraint('alpha_dash'); + } + + /** + * Validate that the string consists only of alphanumeric characters. + */ + public function alphaNum(): self + { + return $this->addConstraint('alpha_num'); + } + + /** + * Validate that the string is numeric consisting of exactly $length digits. + */ + public function digits(int $length): self + { + return $this->addRule(new DigitsRule($length)); + } + + /** + * Validate that the string is numeric consisting between $min and $max digits. + */ + public function digitsBetween(int $min, int $max): self + { + return $this->addRule(new DigitsBetweenRule($min, $max)); + } + + /** + * Validate that the string is a valid email according to the + * egulias/email-validator package. + * + * @param string[] $validationStyle the validation styles to apply. One of: + * + * - rfc: RFCValidation + * - strict: NoRFCWarningsValidation + * - dns: DNSCheckValidation + * - spoof: SpoofCheckValidation + * - filter: FilterEmailValidation + */ + public function email(array $validationStyle = ['rfc']): self + { + return $this->addRule(new EmailRule($validationStyle)); + } + + /** + * Validate that the string ends with one of the given values. + * + * @param string[] $values + * + * @see \Illuminate\Support\Str::endsWith + */ + public function endsWith(array $values): self + { + return $this->addRule(new EndsWithRule($values)); + } + + /** + * Validate that the string is a valid IP address. + */ + public function ip(): self + { + return $this->addConstraint('ip'); + } + + /** + * Validate that the string is a valid IPv4 address. + */ + public function ipv4(): self + { + return $this->addConstraint('ipv4'); + } + + /** + * Validate that the string is a valid IPv6 address. + */ + public function ipv6(): self + { + return $this->addConstraint('ipv6'); + } + + /** + * Validate that the string is a valid json string. + */ + public function json(): self + { + return $this->addConstraint('json'); + } + + /** + * Validate that the string is numeric. + */ + public function numeric(): self + { + return $this->addConstraint('numeric'); + } + + /** + * Validate that the string starts with one of the given values. + * + * @param string[] $values + * + * @see \Illuminate\Support\Str::startsWith + */ + public function startsWith(array $values): self + { + return $this->addRule(new StartsWithRule($values)); + } + + /** + * Validate that the string is a valid timezone. + * + * @see \DateTimeZone::listIdentifiers + */ + public function timezone(): self + { + return $this->addConstraint('timezone'); + } + + /** + * Validate that the string is a valid url. + */ + public function url(): self + { + return $this->addConstraint('url'); + } + + /** + * Validate that the string is a valid UUID + */ + public function uuid(): self + { + return $this->addConstraint('uuid'); + } + + public function getType(): string + { + return 'string'; + } +} diff --git a/src/Apitizer/Validation/TypedRuleBuilder.php b/src/Apitizer/Validation/TypedRuleBuilder.php new file mode 100644 index 0000000..6cbcb89 --- /dev/null +++ b/src/Apitizer/Validation/TypedRuleBuilder.php @@ -0,0 +1,53 @@ + + */ + public function getParameters(): array; + + /** + * Convert the rule to a validation rule that Laravel's Validator can + * understand. + * + * @return string|Rule + */ + public function toValidationRule(); + + /** + * Renders the HTML that will be used by the generated documentation. + * + * This should be kept to a minimum to ensure maximum compatibility with + * custom rendered documentation pages. It should therefore not contain + * anything other than plain inline-tags without additional attributes. + */ + public function toHtml(): string; +} diff --git a/tests/Support/Builders/CommentBuilder.php b/tests/Support/Builders/CommentBuilder.php index f30bd85..956a54d 100644 --- a/tests/Support/Builders/CommentBuilder.php +++ b/tests/Support/Builders/CommentBuilder.php @@ -2,11 +2,10 @@ namespace Tests\Support\Builders; -use Apitizer\QueryBuilder; use Illuminate\Database\Eloquent\Model; use Tests\Feature\Models\Comment; -class CommentBuilder extends QueryBuilder +class CommentBuilder extends EmptyBuilder { public function fields(): array { diff --git a/tests/Support/Builders/EmptyBuilder.php b/tests/Support/Builders/EmptyBuilder.php index f791e72..d35e5de 100644 --- a/tests/Support/Builders/EmptyBuilder.php +++ b/tests/Support/Builders/EmptyBuilder.php @@ -3,6 +3,7 @@ namespace Tests\Support\Builders; use Apitizer\QueryBuilder; +use Apitizer\Validation\Rules; use Tests\Feature\Models\User; use Illuminate\Database\Eloquent\Model; @@ -27,4 +28,8 @@ public function model(): Model { return new User(); } + + public function rules(Rules $rules) + { + } } diff --git a/tests/Support/Builders/PostBuilder.php b/tests/Support/Builders/PostBuilder.php index aee631f..7c0054b 100644 --- a/tests/Support/Builders/PostBuilder.php +++ b/tests/Support/Builders/PostBuilder.php @@ -2,12 +2,11 @@ namespace Tests\Support\Builders; -use Apitizer\QueryBuilder; use Apitizer\Types\Apidoc; use Illuminate\Database\Eloquent\Model; use Tests\Feature\Models\Post; -class PostBuilder extends QueryBuilder +class PostBuilder extends EmptyBuilder { const DESCRIPTION = 'A blog post'; @@ -49,7 +48,7 @@ public function model(): Model return new Post(); } - public function apidoc(Apidoc $apidoc) + public function apidoc(Apidoc $apidoc): void { $apidoc->setDescription(self::DESCRIPTION); } diff --git a/tests/Support/Builders/TagBuilder.php b/tests/Support/Builders/TagBuilder.php index 193a2ab..b94d7fe 100644 --- a/tests/Support/Builders/TagBuilder.php +++ b/tests/Support/Builders/TagBuilder.php @@ -2,11 +2,10 @@ namespace Tests\Support\Builders; -use Apitizer\QueryBuilder; use Illuminate\Database\Eloquent\Model; use Tests\Feature\Models\Tag; -class TagBuilder extends QueryBuilder +class TagBuilder extends EmptyBuilder { public function fields(): array { diff --git a/tests/Support/Builders/UserBuilder.php b/tests/Support/Builders/UserBuilder.php index 22ea144..f60572e 100644 --- a/tests/Support/Builders/UserBuilder.php +++ b/tests/Support/Builders/UserBuilder.php @@ -2,11 +2,10 @@ namespace Tests\Support\Builders; -use Apitizer\QueryBuilder; use Illuminate\Database\Eloquent\Model; use Tests\Feature\Models\User; -class UserBuilder extends QueryBuilder +class UserBuilder extends EmptyBuilder { public function fields(): array { diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php index 2b214d3..1488d1e 100644 --- a/tests/Unit/TestCase.php +++ b/tests/Unit/TestCase.php @@ -4,4 +4,11 @@ class TestCase extends \Tests\TestCase { + public function setUp(): void + { + parent::setUp(); + + // Ensure translations are available in our tests + $this->app['translator']->addNamespace('apitizer', __DIR__.'/../../resources/lang'); + } } diff --git a/tests/Unit/Validation/ArrayRulesTest.php b/tests/Unit/Validation/ArrayRulesTest.php new file mode 100644 index 0000000..cf6657c --- /dev/null +++ b/tests/Unit/Validation/ArrayRulesTest.php @@ -0,0 +1,83 @@ +assertRules([ + 't1' => ['array', 'distinct'], + ], function (ObjectRules $rules) { + $rules->array('t1')->distinct(); + }); + } + + /** @test */ + public function it_nests_validation_rules() + { + $this->assertRules([ + 't1' => ['required', 'array'], + 't1.*' => ['string', 'uuid'], + 't2' => ['array'], + 't2.*' => ['array'], + 't2.*.*' => ['string'], + 't3' => ['array'], + 't3.*' => [], + 't3.*.id' => ['string', 'uuid'], + 't3.*.tags' => ['array'], + 't3.*.tags.*' => ['string', 'uuid'], + ], function (ObjectRules $rules) { + $rules->array('t1')->required()->whereEach()->uuid(); + $rules->array('t2')->whereEach()->array()->whereEach()->string(); + $rules->array('t3')->whereEach()->object(function (ObjectRules $rules) { + $rules->uuid('id'); + $rules->array('tags')->whereEach()->uuid(); + }); + }); + } + + /** @test */ + public function it_supports_all_types_for_the_array_elements() + { + $this->assertRules([ + 't1' => ['array'], + 't1.*' => ['string'], + 't2' => ['array'], + 't2.*' => ['string', 'uuid'], + 't3' => ['array'], + 't3.*' => ['boolean'], + 't4' => ['array'], + 't4.*' => ['date'], + 't5' => ['array'], + 't5.*' => ['date_format:' . DATE_ATOM], + 't6' => ['array'], + 't6.*' => ['numeric'], + 't7' => ['array'], + 't7.*' => ['integer'], + 't8' => ['array'], + 't8.*' => ['file'], + 't9' => ['array'], + 't9.*' => ['file', 'image'], + 't10' => ['array'], + 't10.*' => [], + 't10.*.name' => ['string'], + ], function (ObjectRules $rules) { + $rules->array('t1')->whereEach()->string(); + $rules->array('t2')->whereEach()->uuid(); + $rules->array('t3')->whereEach()->boolean(); + $rules->array('t4')->whereEach()->date(); + $rules->array('t5')->whereEach()->datetime(); + $rules->array('t6')->whereEach()->number(); + $rules->array('t7')->whereEach()->integer(); + $rules->array('t8')->whereEach()->file(); + $rules->array('t9')->whereEach()->image(); + $rules->array('t10')->whereEach()->object(function (ObjectRules $rules) { + $rules->string('name'); + }); + }); + } +} diff --git a/tests/Unit/Validation/BooleanRulesTest.php b/tests/Unit/Validation/BooleanRulesTest.php new file mode 100644 index 0000000..56b905d --- /dev/null +++ b/tests/Unit/Validation/BooleanRulesTest.php @@ -0,0 +1,20 @@ +assertRules([ + 't1' => ['required', 'boolean'], + 't2' => ['boolean', 'accepted'], + ], function (ObjectRules $rules) { + $rules->boolean('t1')->required(); + $rules->boolean('t2')->accepted(); + }); + } +} diff --git a/tests/Unit/Validation/CommonRulesTest.php b/tests/Unit/Validation/CommonRulesTest.php new file mode 100644 index 0000000..c71b891 --- /dev/null +++ b/tests/Unit/Validation/CommonRulesTest.php @@ -0,0 +1,62 @@ +assertRules([ + 't1' => ['string', 'required_with:t1,t2'], + 't2' => ['string', 'required_with_all:t1,t2'], + 't3' => ['string', 'required_without:t1,t2'], + 't4' => ['string', 'required_without_all:t1,t2'], + 't5' => ['string', 'nullable'], + 't6' => ['string', 'size:50'], + 't7' => ['string', 'max:50'], + 't8' => ['string', 'min:20'], + 't9' => ['string', 'bail'], + 't10' => ['string', 'confirmed'], + 't11' => ['string', 'between:5,10'], + 't12' => ['string', 'different:t1'], + 't13' => ['string', 'same:t1'], + 't14' => ['string', 'regex:/hello/'], + 't15' => ['string', 'not_regex:/hello/'], + 't16' => ['string', 'gt:t1'], + 't17' => ['string', 'gte:t1'], + 't18' => ['string', 'lt:t1'], + 't19' => ['string', 'lte:t1'], + 't20' => ['string', 'in_array:t1.*'], + 't21' => ['string', 'filled'], + 't22' => ['string', 'sometimes'], + 't23' => ['string', 'present'], + ], function (ObjectRules $rules) { + $rules->string('t1')->requiredWith(['t1', 't2']); + $rules->string('t2')->requiredWithAll(['t1', 't2']); + $rules->string('t3')->requiredWithout(['t1', 't2']); + $rules->string('t4')->requiredWithoutAll(['t1', 't2']); + $rules->string('t5')->nullable(); + $rules->string('t6')->size(50); + $rules->string('t7')->max(50); + $rules->string('t8')->min(20); + $rules->string('t9')->bail(); + $rules->string('t10')->confirmed(); + $rules->string('t11')->between(5, 10); + $rules->string('t12')->different('t1'); + $rules->string('t13')->same('t1'); + $rules->string('t14')->regex('/hello/'); + $rules->string('t15')->notRegex('/hello/'); + $rules->string('t16')->gt('t1'); + $rules->string('t17')->gte('t1'); + $rules->string('t18')->lt('t1'); + $rules->string('t19')->lte('t1'); + $rules->string('t20')->inArray('t1'); + $rules->string('t21')->filled(); + $rules->string('t22')->sometimes(); + $rules->string('t23')->present(); + }); + } +} diff --git a/tests/Unit/Validation/DateRulesTest.php b/tests/Unit/Validation/DateRulesTest.php new file mode 100644 index 0000000..7f69118 --- /dev/null +++ b/tests/Unit/Validation/DateRulesTest.php @@ -0,0 +1,87 @@ +assertRules([ + 't1' => ['date'], + 't2' => ['date_format:' . DATE_ATOM], + ], function (ObjectRules $rules) { + $rules->date('t1'); + $rules->datetime('t2'); + }); + } + + /** @test */ + public function it_validates_after() + { + $tomorrow = Carbon::tomorrow(); + + $this->assertRules([ + 't1' => ['date', 'after:' . $tomorrow->format('Y-m-d')], + 't2' => ['date_format:'.DATE_ATOM, 'after:' . $tomorrow->format(DATE_ATOM)], + ], function (ObjectRules $rules) use ($tomorrow) { + $rules->date('t1')->after($tomorrow); + $rules->datetime('t2')->after($tomorrow); + }); + } + + /** @test */ + public function it_validates_after_or_equal() + { + $tomorrow = Carbon::tomorrow(); + + $this->assertRules([ + 't1' => ['date', 'after_or_equal:' . $tomorrow->format('Y-m-d')], + 't2' => ['date_format:' . DATE_ATOM, 'after_or_equal:' . $tomorrow->format(DATE_ATOM)], + ], function (ObjectRules $rules) use ($tomorrow) { + $rules->date('t1')->afterOrEqual($tomorrow); + $rules->datetime('t2')->afterOrEqual($tomorrow); + }); + } + + /** @test */ + public function it_validates_before() + { + $tomorrow = Carbon::tomorrow(); + + $this->assertRules([ + 't1' => ['date', 'before:' . $tomorrow->format('Y-m-d')], + 't2' => ['date_format:' . DATE_ATOM, 'before:' . $tomorrow->format(DATE_ATOM)], + ], function (ObjectRules $rules) use ($tomorrow) { + $rules->date('t1')->before($tomorrow); + $rules->datetime('t2')->before($tomorrow); + }); + } + + /** @test */ + public function it_validates_before_or_equal() + { + $tomorrow = Carbon::tomorrow(); + + $this->assertRules([ + 't1' => ['date', 'before_or_equal:' . $tomorrow->format('Y-m-d')], + 't2' => ['date_format:' . DATE_ATOM, 'before_or_equal:' . $tomorrow->format(DATE_ATOM)], + ], function (ObjectRules $rules) use ($tomorrow) { + $rules->date('t1')->beforeOrEqual($tomorrow); + $rules->datetime('t2')->beforeOrEqual($tomorrow); + }); + } + + /** @test */ + public function it_validates_the_equals_rule() + { + $this->assertRules([ + 't1' => ['date', 'date_equals:2020-01-01'], + ], function (ObjectRules $rules) { + $rules->date('t1')->equals(Carbon::parse('2020-01-01')); + }); + } +} diff --git a/tests/Unit/Validation/FileRulesTest.php b/tests/Unit/Validation/FileRulesTest.php new file mode 100644 index 0000000..733c48a --- /dev/null +++ b/tests/Unit/Validation/FileRulesTest.php @@ -0,0 +1,53 @@ +assertRules([ + 't1' => ['file'], + 't2' => ['file', 'image'], + ], function (ObjectRules $rules) { + $rules->file('t1'); + $rules->image('t2'); + }); + } + + /** @test */ + public function it_validates_mimetypes() + { + $this->assertRules([ + 't1' => ['file', 'mimetypes:image/jpeg'], + 't2' => ['file', 'mimes:jpeg,csv'], + ], function (ObjectRules $rules) { + $rules->file('t1')->mimetypes(['image/jpeg']); + $rules->file('t2')->mimes(['jpeg', 'csv']); + }); + } + + /** @test */ + public function it_validates_dimensions() + { + $dimensions = (new DimensionsRule()) + ->height(100) + ->width(200) + ->maxHeight(300) + ->maxWidth(400) + ->ratio(1 / 2); + + $this->assertRules([ + 't1' => [ + 'file', 'image', + 'dimensions:height=100,width=200,max_height=300,max_width=400,ratio=0.5' + ] + ], function (ObjectRules $rules) use ($dimensions) { + $rules->image('t1')->dimensions($dimensions); + }); + } +} diff --git a/tests/Unit/Validation/NumberRulesTest.php b/tests/Unit/Validation/NumberRulesTest.php new file mode 100644 index 0000000..c711e7b --- /dev/null +++ b/tests/Unit/Validation/NumberRulesTest.php @@ -0,0 +1,44 @@ +assertRules([ + 't1' => ['numeric'], + 't2' => ['integer'], + ], function (ObjectRules $rules) { + $rules->number('t1'); + $rules->integer('t2'); + }); + } + + /** @test */ + public function it_validates_starts_and_ends_with() + { + $this->assertRules([ + 't1' => ['numeric', 'ends_with:name,wow'], + 't1' => ['numeric', 'starts_with:name,wow'], + ], function (ObjectRules $rules) { + $rules->number('t1')->endsWith(['name', 'wow']); + $rules->number('t1')->startsWith(['name', 'wow']); + }); + } + + /** @test */ + public function it_validates_digits() + { + $this->assertRules([ + 't1' => ['numeric', 'digits:5'], + 't2' => ['numeric', 'digits_between:14,34'], + ], function (ObjectRules $rules) { + $rules->number('t1')->digits(5); + $rules->number('t2')->digitsBetween(14, 34); + }); + } +} diff --git a/tests/Unit/Validation/RulesTest.php b/tests/Unit/Validation/RulesTest.php new file mode 100644 index 0000000..616a9cd --- /dev/null +++ b/tests/Unit/Validation/RulesTest.php @@ -0,0 +1,87 @@ +define('store', function (ObjectRules $rules) { + $rules->string('name'); + }); + + $storeRules = $rules->getBuilder('store'); + + $this->assertInstanceOf(ObjectRules::class, $storeRules); + $this->assertCount(1, $storeRules->getChildren()); + } + + /** @test */ + public function it_returns_an_empty_object_when_none_is_defined() + { + $rules = new Rules(); + $object = $rules->getBuilder('store'); + + $this->assertInstanceOf(ObjectRules::class, $object); + $this->assertEmpty($object->getChildren()); + } + + /** @test */ + public function it_resolves_all_builders_when_all_rules_are_requested() + { + $rules = new Rules(); + $rules->storeRules(function (ObjectRules $builder) {}); + $rules->updateRules(function (ObjectRules $builder) {}); + + $rules = $rules->getValidationRules(); + + $this->assertCount(2, $rules); + $this->assertEquals([ + 'store' => [], + 'update' => [] + ], $rules); + } + + /** @test */ + public function it_resolves_a_single_builder_when_only_one_is_requested() + { + $rules = new Rules(); + $rules->storeRules(function (ObjectRules $builder) {}); + $rules->updateRules(function (ObjectRules $builder) { + $this->fail("The update rules should not be resolved"); + }); + + $rules->getValidationRulesForAction('store'); + + // Pass the test, since "fail" was never called. + // We cant use "expectNotToPerformAssertions" because those tests are + // removed from coverage. + $this->addToAssertionCount(1); + } + + /** @test */ + public function it_only_resolves_rules_once_for_each_action() + { + $count = 0; + $rules = new Rules(); + $rules->storeRules(function (ObjectRules $builder) use (&$count) { + if (++$count > 1) { + $this->fail(); + } + }); + + // Call it twice. + $rules->getValidationRulesForAction('store'); + $rules->getValidationRulesForAction('store'); + + // Pass the test, since "fail" was never called. + // We cant use "expectNotToPerformAssertions" because those tests are + // removed from coverage. + $this->addToAssertionCount(1); + } +} diff --git a/tests/Unit/Validation/StringRulesTest.php b/tests/Unit/Validation/StringRulesTest.php new file mode 100644 index 0000000..c758111 --- /dev/null +++ b/tests/Unit/Validation/StringRulesTest.php @@ -0,0 +1,52 @@ +assertRules([ + 't1' => ['string', 'active_url'], + 't2' => ['string', 'alpha'], + 't3' => ['string', 'alpha_dash'], + 't4' => ['string', 'alpha_num'], + 't5' => ['string', 'ip'], + 't6' => ['string', 'ipv4'], + 't7' => ['string', 'ipv6'], + 't8' => ['string', 'json'], + 't9' => ['string', 'numeric'], + 't10' => ['string', 'timezone'], + 't11' => ['string', 'url'], + 't12' => ['string', 'uuid'], + 't13' => ['string', 'digits:5'], + 't14' => ['string', 'digits_between:5,10'], + 't15' => ['string', 'starts_with:name'], + 't16' => ['string', 'ends_with:name'], + 't17' => ['string', 'email:rfc'], + 't18' => ['string', 'email:spoof,filter,dns'], + ], function (ObjectRules $rules) { + $rules->string('t1')->activeUrl(); + $rules->string('t2')->alpha(); + $rules->string('t3')->alphaDash(); + $rules->string('t4')->alphaNum(); + $rules->string('t5')->ip(); + $rules->string('t6')->ipv4(); + $rules->string('t7')->ipv6(); + $rules->string('t8')->json(); + $rules->string('t9')->numeric(); + $rules->string('t10')->timezone(); + $rules->string('t11')->url(); + $rules->string('t12')->uuid(); + $rules->string('t13')->digits(5); + $rules->string('t14')->digitsBetween(5, 10); + $rules->string('t15')->startsWith(['name']); + $rules->string('t16')->endsWith(['name']); + $rules->string('t17')->email(); + $rules->string('t18')->email(['spoof', 'filter', 'dns']); + }); + } +} diff --git a/tests/Unit/Validation/TestCase.php b/tests/Unit/Validation/TestCase.php new file mode 100644 index 0000000..d46771b --- /dev/null +++ b/tests/Unit/Validation/TestCase.php @@ -0,0 +1,24 @@ +resolve(); + $rules = RuleInterpreter::rulesFrom($object); + + PHPUnit::assertEquals( + $expected, + $rules, + "The generated rules did not match the expected rules" + ); + } +}