limoncello-php / app

Quick start JSON API application
MIT License
83 stars 7 forks source link

Usage of Validator #26

Open dreamsbond opened 7 years ago

dreamsbond commented 7 years ago

@neomerx is it possible to have documentation for validator usage? as i think i was not very familiar with the part doing conversion, e.g.: stringtoadateline, etc... having some custom validation logic, but cant find entry point of gettings values from request and making it usable on validator

dreamsbond commented 7 years ago

also having some problem validating datetime.

e.g.: self::isDateTime(self::stringToDateTime(DateTimeBaseType::JSON_API_FORMAT)); with ISO8601 dateformat e.g "2016-10-01T00:00:00+0000" fails in test and return

[items:Neomerx\JsonApi\Exceptions\ErrorCollection:private] => Array
    (
        [0] => Neomerx\JsonApi\Document\Error Object
            (
                [idx:Neomerx\JsonApi\Document\Error:private] =>
                [links:Neomerx\JsonApi\Document\Error:private] =>
                [status:Neomerx\JsonApi\Document\Error:private] => 422
                [code:Neomerx\JsonApi\Document\Error:private] =>
                [title:Neomerx\JsonApi\Document\Error:private] => The value is invalid.
                [detail:Neomerx\JsonApi\Document\Error:private] => The value should be a valid date time.
                [source:Neomerx\JsonApi\Document\Error:private] => Array
                    (
                        [pointer] => /data/attributes/dateline
                    )

                [meta:Neomerx\JsonApi\Document\Error:private] =>
            )

    )
dreamsbond commented 7 years ago

as well as self::isInt(self::stringtoInt());

neomerx commented 7 years ago

Can you post a few examples you've got difficulties with?

JSON API Validator parses a JSON API document and validates it looks like a valid one. Then it passes actual string values to validatation rules. As a developer you describe your rules in JsonApiRuleSetInterface which contains rules for such JSON API document parts as id, type, individual attributes and individual relationships.

Now how rules work.

1) You work with dates Expected value string v::stringToDateTime(DateTimeBaseType::JSON_API_FORMAT) it will check input value as a string and will convert it to a date time with the format given

2) Rule chaining Rules support chaining one rule with another. Let's say you can convert a string to int time and then check its value is within a specified range. v::stringtoInt(v::between(5, 10)) The first rule has a string as an input and the second one will have int. So when you write self::isInt(self::stringtoInt()) you firstly check string is int (error).

Can you post a few examples so I can help you with?

dreamsbond commented 7 years ago

i did some tests: with json input as below:

        $jsonInput = <<<EOT
    {
        "data" : {
            "type"  : "submissions",
            "id"    : null,
            "attributes": {
                "dateline": "2017-01-01T00:00:00+0000",
                "present": "true"
            }
        }
    }

EOT;

in SubmissionRules:

i put the rules as:

/**
 * @return RuleInterface
 */
public static function dateline(): RuleInterface
{
    return self::isDateTime(self::stringToDateTime(DateTimeBaseType::JSON_API_FORMAT));
}

and

/**
 * @return RuleInterface
 */
public static function present(): RuleInterface
{
    return self::isBool(self::stringToBool());
}

and SubmissionApiTests throw exception as below: . 1 / 1 (100%)Neomerx\JsonApi\Exceptions\ErrorCollection Object ( [items:Neomerx\JsonApi\Exceptions\ErrorCollection:private] => Array ( [0] => Neomerx\JsonApi\Document\Error Object ( [idx:Neomerx\JsonApi\Document\Error:private] => [links:Neomerx\JsonApi\Document\Error:private] => [status:Neomerx\JsonApi\Document\Error:private] => 422 [code:Neomerx\JsonApi\Document\Error:private] => [title:Neomerx\JsonApi\Document\Error:private] => The value is invalid. [detail:Neomerx\JsonApi\Document\Error:private] => The value should be a valid date time. [source:Neomerx\JsonApi\Document\Error:private] => Array ( [pointer] => /data/attributes/dateline )

                [meta:Neomerx\JsonApi\Document\Error:private] =>
            )

        [1] => Neomerx\JsonApi\Document\Error Object
            (
                [idx:Neomerx\JsonApi\Document\Error:private] =>
                [links:Neomerx\JsonApi\Document\Error:private] =>
                [status:Neomerx\JsonApi\Document\Error:private] => 422
                [code:Neomerx\JsonApi\Document\Error:private] =>
                [title:Neomerx\JsonApi\Document\Error:private] => The value is invalid.
                [detail:Neomerx\JsonApi\Document\Error:private] => The value should be a boolean.
                [source:Neomerx\JsonApi\Document\Error:private] => Array
                    (
                        [pointer] => /data/attributes/archive
                    )

                [meta:Neomerx\JsonApi\Document\Error:private] =>
            )

    )

)

i just put your suggested rule in and now:

the dateline exception gone present remain throwing exception

dreamsbond commented 7 years ago

for belongs to relationship

i saw return self::required(self::toOneRelationship(BoardScheme::TYPE, BoardRules::isBoardId()));

is the self::required compulsory?

dreamsbond commented 7 years ago

some updates:

it was abit rare that, i re-run the test.

with:

/**
 * @return RuleInterface
 */
public static function dateline(): RuleInterface
{
    return self::stringToDateTime(DateTimeBaseType::JSON_API_FORMAT);
}

it throws:

ErrorException: strlen() expects parameter 1 to be string, object given

neomerx commented 7 years ago

for your input

    {
        "data" : {
            "type"  : "submissions",
            "id"    : null,
            "attributes": {
                "dateline": "2017-01-01T00:00:00+0000",
                "present": "true"
            }
        }
    }

rules should be

$typeRule = v::equals('submissions');
$idRule = v::equals(null);
$attrRules = [
  'dateline' => v::stringToDateTime(DateTimeBaseType::JSON_API_FORMAT),
  'present' => v::stringToBool(),
];

adding v::requied makes input field mandatory (will produce an error if not given)

If you post an example for relationships I can help you with it as well

dreamsbond commented 7 years ago

that means if stringToXX present. isXXX is not required to be chained right?

dreamsbond commented 7 years ago

thanks @neomerx, the boolean and integer part were also solved with you guidance of using proper chaining.

but the datetime part wasn't solved yet. it keep throwing "ErrorException: strlen() expects parameter 1 to be string, object given"

neomerx commented 7 years ago

isXXX almost never needed for JSON API inputs. It's for other validators. In JSON API you've got strings as inputs so after stringToXXX you always have the correct type. As for stringToDateTime I don't see it uses strlen. Can you please provide more details in input data and the rule itself?

dreamsbond commented 7 years ago
<?php namespace App\Data\Models;

use Doctrine\DBAL\Types\Type;
use Limoncello\Contracts\Application\ModelInterface;
use Limoncello\Contracts\Data\RelationshipTypes;
use Limoncello\Flute\Types\JsonApiDateTimeType;

/**
 * Class Submission
 *
 * @package App\Data\Models
 */
class Submission implements ModelInterface, CommonFields
{
    /**
     * Table name
     */
    const TABLE_NAME = 'submissions';
    /**
     * Primary key
     */
    const FIELD_ID = 'id_submission';

    /**
     * Foreign key
     */
    const FIELD_ID_USER = User::FIELD_ID;

    /**
     * Field name
     */
    const FIELD_DATELINE = 'dateline';
    /**
     * Field name
     */
    const FIELD_PRESENT = 'present';
    /**
     * Field name
     */
    const FIELD_DESCRIPTION = 'description';

    /**
     * Field name
     */
    const FIELD_DISPLAY_ORDER = 'display_order';

    /**
     * Relationship name
     */
    const REL_USER = 'user';

    /**
     * @inheritDoc
     */
    public static function getTableName(): string
    {
        return static::TABLE_NAME;
    }

    /**
     * @inheritDoc
     */
    public static function getPrimaryKeyName(): string
    {
        return static::FIELD_ID;
    }

    /**
     * @inheritDoc
     */
    public static function getAttributeTypes(): array
    {
        return [
            self::FIELD_ID            => Type::INTEGER,
            self::FIELD_ID_USER       => Type::INTEGER,
            self::FIELD_DATELINE      => JsonApiDateTimeType::NAME,
            self::FIELD_PRESENT       => Type::BOOLEAN,
            self::FIELD_DESCRIPTION   => Type::TEXT,
            self::FIELD_DISPLAY_ORDER => Type::INTEGER,
            self::FIELD_CREATED_AT    => JsonApiDateTimeType::NAME,
            self::FIELD_UPDATED_AT    => JsonApiDateTimeType::NAME,
            self::FIELD_DELETED_AT    => JsonApiDateTimeType::NAME,
        ];
    }

    /**
     * @inheritDoc
     */
    public static function getAttributeLengths(): array
    {
        return [];
    }

    /**
     * @inheritDoc
     */
    public static function getRelationships(): array
    {
        return [
            RelationshipTypes::BELONGS_TO => [
                self::REL_USER => [User::class, self::FIELD_ID_USER, User::REL_SUBMISSIONS],
            ],
        ];
    }
}
dreamsbond commented 7 years ago
<?php namespace App\Json\Schemes;

use App\Data\Models\Submission as Model;

/**
 * Class SubmissionScheme
 *
 * @package App\Json\Schemes
 */
class SubmissionScheme extends BaseScheme
{
    /**
     * Attribute type
     */
    const TYPE = 'submissions';
    /**
     * Model class name
     */
    const MODEL = Model::class;

    /**
     * Attribute name
     */
    const ATTR_DATELINE = Model::FIELD_DATELINE;
    /**
     * Attribute name
     */
    const ATTR_PRESENT = Model::FIELD_PRESENT;
    /**
     * Attribute name
     */
    const ATTR_DESCRIPTION = Model::FIELD_DESCRIPTION;

    /**
     * Attribute name
     */
    const ATTR_DISPLAY_ORDER = 'display-order';

    /**
     * Relationship name
     */
    const REL_USER = Model::REL_USER;

    /**
     * @inheritDoc
     */
    public static function getMappings(): array
    {
        return [
            self::SCHEMA_ATTRIBUTES    => [
                self::ATTR_DATELINE      => Model::FIELD_DATELINE,
                self::ATTR_PRESENT       => Model::FIELD_PRESENT,
                self::ATTR_DESCRIPTION   => Model::FIELD_DESCRIPTION,
                self::ATTR_DISPLAY_ORDER => Model::FIELD_DISPLAY_ORDER,
                self::ATTR_CREATED_AT    => Model::FIELD_CREATED_AT,
                self::ATTR_UPDATED_AT    => Model::FIELD_UPDATED_AT,
                self::ATTR_DELETED_AT    => Model::FIELD_DELETED_AT,
            ],
            self::SCHEMA_RELATIONSHIPS => [
                self::REL_USER                 => Model::REL_USER,
            ],
        ];
    }

    /**
     * @inheritDoc
     */
    protected function getExcludesFromDefaultShowSelfLinkInRelationships(): array
    {
        return [
            self::REL_USER => true,
        ];
    }

    /**
     * @inheritDoc
     */
    protected function getExcludesFromDefaultShowRelatedLinkInRelationships(): array
    {
        return [
            self::REL_USER => true,
        ];
    }

}
neomerx commented 7 years ago

And rules?

dreamsbond commented 7 years ago
<?php namespace App\Authorization;

use App\Data\Models\Submission as Model;
use App\Json\Api\SubmissionsApi as Api;
use App\Json\Schemes\SubmissionScheme as Scheme;
use Limoncello\Application\Contracts\Authorization\ResourceAuthorizationRulesInterface;
use Limoncello\Auth\Contracts\Authorization\PolicyInformation\ContextInterface;
use Limoncello\Flute\Contracts\FactoryInterface;
use Settings\Passport;

/**
 * Class SubmissionRules
 *
 * @package App\Authorization
 */
class SubmissionRules implements ResourceAuthorizationRulesInterface
{
    use RulesTrait;

    /**
     * Action name
     */
    const ACTION_VIEW_SUBMISSIONS = 'canViewSubmissions';

    /**
     * Action name
     */
    const ACTION_CREATE_SUBMISSIONS = 'canCreateSubmissions';

    /**
     * Action name
     */
    const ACTION_EDIT_SUBMISSIONS = 'canEditSubmissions';

    /**
     * @inheritdoc
     */
    public static function getResourcesType(): string
    {
        return Scheme::TYPE;
    }

    /**
     * @param ContextInterface $context
     *
     * @return bool
     */
    public static function canViewSubmissions(ContextInterface $context): bool
    {
        return self::hasScope($context, Passport::SCOPE_ADMIN_SUBMISSIONS) || self::hasScope($context, Passport::SCOPE_VIEW_SUBMISSIONS);
    }

    /**
     * @param ContextInterface $context
     *
     * @return bool
     */
    public static function canCreateSubmissions(ContextInterface $context): bool
    {
        return self::hasScope($context, Passport::SCOPE_ADMIN_SUBMISSIONS);
    }

    /**
     * @param ContextInterface $context
     *
     * @return bool
     */
    public static function canEditSubmissions(ContextInterface $context): bool
    {
        return self::hasScope($context, Passport::SCOPE_ADMIN_SUBMISSIONS);
    }

    /**
     * @param ContextInterface $context
     *
     * @return bool
     */
    private static function isSubmissionOwner(ContextInterface $context): bool
    {
        $isSubmissionOwner = false;

        if (($userId = self::getCurrentUserIdentity($context)) !== null) {
            $identity = self::reqGetResourceIdentity($context);

            $container = self::ctxGetContainer($context);
            $factory = $container->get(FactoryInterface::class);
            $api = $factory->createApi(Api::class);
            $submission = $api->readResource($identity);
            $isSubmissionOwner = $submission !== null && $submission->{Model::FIELD_ID_USER} === $userId;
        }

        return $isSubmissionOwner;
    }
}
dreamsbond commented 7 years ago
<?php

namespace App\Http\Controllers;

use App\Json\Api\SubmissionsApi as Api;
use App\Json\Schemes\SubmissionScheme as Scheme;
use App\Json\Validators\SubmissionCreate;
use App\Json\Validators\SubmissionUpdate;

/**
 * Class SubmissionsController
 *
 * @package App\Http\Controllers
 */
class SubmissionsController extends BaseController
{
    /**
     * Api class name
     */
    const API_CLASS = Api::class;

    /**
     * Schema class name
     */
    const SCHEMA_CLASS = Scheme::class;

    /**
     * Validator class name
     */
    const ON_CREATE_VALIDATION_RULES_SET_CLASS = SubmissionCreate::class;

    /**
     * Validator class name
     */
    const ON_UPDATE_VALIDATION_RULES_SET_CLASS = SubmissionUpdate::class;
}
dreamsbond commented 7 years ago

The description field is mapped to LONGTEXT in mysql; i used isString() for validation

<?php

namespace App\Json\Validators\Rules;

use App\Data\Models\Submission as Model;
use App\Json\Schemes\SubmissionScheme as Scheme;
use App\Json\Schemes\UserScheme;
use Limoncello\Flute\Types\DateTimeBaseType;
use Limoncello\Flute\Validation\Rules\ExistInDatabaseTrait;
use Limoncello\Flute\Validation\Rules\RelationshipsTrait;
use Limoncello\Validation\Contracts\Rules\RuleInterface;
use Limoncello\Validation\Rules;
use PHPMD\Rule;

/**
 * Class SubmissionRules
 *
 * @package App\Json\Validators\Rules
 */
final class SubmissionRules extends Rules
{
    use RelationshipsTrait, ExistInDatabaseTrait;

    /**
     * @return RuleInterface
     */
    public static function isSubmissionType(): RuleInterface
    {
        return self::equals(Scheme::TYPE);
    }

    /**
     * @return RuleInterface
     */
    public static function isSubmissionId(): RuleInterface
    {
        return self::stringToInt(self::exists(Model::TABLE_NAME, Model::FIELD_ID));
    }

    /**
     * @return RuleInterface
     */
    public static function isUserRelationship(): RuleInterface
    {
        return self::toOneRelationship(UserScheme::TYPE, UserRules::isUserId());
    }

    /**
     * @return RuleInterface
     */
    public static function dateline(): RuleInterface
    {
        return self::stringToDateTime(DateTimeBaseType::JSON_API_FORMAT);
    }

    public static function present(): RuleInterface
    {
        return self::stringToBool();
    }

    /**
     * @return RuleInterface
     */
    public static function description(): RuleInterface
    {
        return self::isString();
    }

    /**
     * @return RuleInterface
     */
    public static function displayOrder(): RuleInterface
    {
        return self::stringToInt();
    }
}
neomerx commented 7 years ago

so far seems fine. What about SubmissionCreate and SubmissionUpdate?

dreamsbond commented 7 years ago
  public function testCreate()
    {
        try {
            $this->setPreventCommits();

            $jsonInput = <<<EOT
        {
            "data" : {
                "type"  : "submissions",
                "id"    : null,
                "attributes": {
                    "dateline": "2017-01-01T00:00:00+0800",
                    "present": "-1",
                    "display-order": "AAA"                    
                },
                "relationships": {
                    "user": {
                        "data": { "type": "users", "id": "55" }
                    }
                }
            }
        }
EOT;

            $headers = ['Authorization' => 'Bearer ' . $this->getAdministratorsOAuthToken()];
            $response = $this->postJsonApi(self::API_URI, $jsonInput, $headers);
            $this->assertEquals(201, $response->getStatusCode());

            $json = json_decode((string)$response->getBody());
            $this->assertObjectHasAttribute('data', $json);
            $submissionId = $json->data->id;

            $this->assertEquals(200, $this->get(self::API_URI . "/$submissionId", [], $headers)->getStatusCode());

            $query = $this->getCapturedConnection()->createQueryBuilder();
            $statement = $query
                ->select('*')
                ->from(Submission::TABLE_NAME)
                ->where(Submission::FIELD_ID . '=' . $query->createPositionalParameter($submissionId))
                ->execute();
            $this->assertNotEmpty($values = $statement->fetch());
            $this->assertNotEmpty($values[ Submission::FIELD_CREATED_AT ]);
        } catch (JsonApiException $e) {
            print_r($e->getErrors());
        }
    }
dreamsbond commented 7 years ago
<?php

namespace App\Json\Validators;

use App\Json\Schemes\SubmissionScheme as Scheme;
use App\Json\Validators\Rules\SubmissionRules as v;
use Limoncello\Flute\Contracts\Validation\JsonApiRuleSetInterface;
use Limoncello\Flute\Types\DateTimeBaseType;
use Limoncello\Validation\Contracts\Rules\RuleInterface;

/**
 * Class SubmissionCreate
 *
 * @package App\Json\Validators
 */
class SubmissionCreate implements JsonApiRuleSetInterface
{
    /**
     * @inheritDoc
     */
    public static function getTypeRule(): RuleInterface
    {
        return v::isSubmissionType();
    }

    /**
     * @inheritDoc
     */
    public static function getIdRule(): RuleInterface
    {
        return v::equals(null);
    }

    /**
     * @inheritDoc
     */
    public static function getAttributeRules(): array
    {
        return [
            Scheme::ATTR_DATELINE      => v::required(v::dateline()),
            Scheme::ATTR_PRESENT       => v::required(v::present()),
            Scheme::ATTR_DESCRIPTION   => v::description(),
            Scheme::ATTR_DISPLAY_ORDER => v::displayOrder(),
        ];
    }

    /**
     * @inheritDoc
     */
    public static function getToOneRelationshipRules(): array
    {
        return [
            Scheme::REL_USER                 => v::required(v::isUserRelationship()),
        ];
    }

    /**
     * @inheritDoc
     */
    public static function getToManyRelationshipRules(): array
    {
        return [];
    }
}
dreamsbond commented 7 years ago
<?php

namespace App\Json\Validators;

use App\Json\Schemes\SubmissionScheme as Scheme;
use App\Json\Validators\Rules\SubmissionRules as v;
use Limoncello\Flute\Contracts\Validation\JsonApiRuleSetInterface;
use Limoncello\Validation\Contracts\Rules\RuleInterface;

/**
 * Class SubmissionUpdate
 *
 * @package App\Json\Validators
 */
class SubmissionUpdate implements JsonApiRuleSetInterface
{
    /**
     * @inheritDoc
     */
    public static function getTypeRule(): RuleInterface
    {
        return v::isSubmissionType();
    }

    /**
     * @inheritDoc
     */
    public static function getIdRule(): RuleInterface
    {
        return v::isSubmissionId();
    }

    /**
     * @inheritDoc
     */
    public static function getAttributeRules(): array
    {
        return [
            Scheme::ATTR_DATELINE      => v::dateline(),
            Scheme::ATTR_PRESENT       => v::present(),
            Scheme::ATTR_DESCRIPTION   => v::description(),
            Scheme::ATTR_DISPLAY_ORDER => v::displayOrder(),
        ];
    }

    /**
     * @inheritDoc
     */
    public static function getToOneRelationshipRules(): array
    {
        return [
            Scheme::REL_USER                 => v::isUserRelationship(),
        ];
    }

    /**
     * @inheritDoc
     */
    public static function getToManyRelationshipRules(): array
    {
        return [];
    }
}
dreamsbond commented 7 years ago

i runned the test again,

            $jsonInput = <<<EOT
        {
            "data" : {
                "type"  : "submissions",
                "id"    : null,
                "attributes": {
                    "dateline": "2017-01-01T00:00:00+0800",
                    "present": "-1",
                    "display-order": "AAA"                    
                },
                "relationships": {
                    "user": {
                        "data": { "type": "users", "id": "55" }
                    }
                }
            }
        }
EOT;

it was rare again, the dateline doesn't throw "ErrorException: strlen() expects parameter 1 to be string, object given" now. also, as you see the "display-order" should throw exception but it doesn't

neomerx commented 7 years ago

Can you provide stack for 'strlen() expects parameter 1 to be string, object given'?

display-order will not produce any error. For PHP 'AAA' is a valid input to convert it to int. It can be checked for numeric but let's do it later. The 'strlen' error first.

dreamsbond commented 7 years ago

@neomerx let me try reproduce it.

dreamsbond commented 7 years ago
There was 1 error:

1) Tests\SubmissionApiTest::testCreate
ErrorException: strlen() expects parameter 1 to be string, object given

/timable/limoncello-php/vendor/doctrine/dbal/lib/Doctrine/DBAL/Types/ConversionException.php:44
/timable/limoncello-php/vendor/limoncello-php/flute/src/Types/JsonApiDateTimeType.php:53
/timable/limoncello-php/vendor/limoncello-php/flute/src/Adapters/Repository.php:140
/timable/limoncello-php/vendor/limoncello-php/flute/src/Api/Crud.php:320
/timable/limoncello-php/app/Json/Api/SubmissionsApi.php:100
/timable/limoncello-php/vendor/limoncello-php/flute/src/Http/BaseController.php:112
/timable/limoncello-php/vendor/limoncello-php/core/src/Application/Application.php:285
/timable/limoncello-php/vendor/limoncello-php/core/src/Application/Application.php:351
/timable/limoncello-php/app/Json/Exceptions/ApiHandler.php:35
/timable/limoncello-php/vendor/limoncello-php/core/src/Application/Application.php:423
/timable/limoncello-php/vendor/limoncello-php/passport/src/Authentication/PassportMiddleware.php:66
/timable/limoncello-php/vendor/limoncello-php/core/src/Application/Application.php:423
/timable/limoncello-php/vendor/limoncello-php/application/src/Packages/Cors/CorsMiddleware.php:53
/timable/limoncello-php/vendor/limoncello-php/core/src/Application/Application.php:423
/timable/limoncello-php/vendor/limoncello-php/core/src/Application/Application.php:186
/timable/limoncello-php/vendor/limoncello-php/testing/src/ApplicationWrapperTrait.php:160
/timable/limoncello-php/vendor/limoncello-php/core/src/Application/Application.php:146
/timable/limoncello-php/vendor/limoncello-php/testing/src/TestCaseTrait.php:106
/timable/limoncello-php/vendor/limoncello-php/testing/src/JsonApiCallsTrait.php:47
/timable/limoncello-php/tests/SubmissionApiTest.php:115

ERRORS!
neomerx commented 7 years ago

Aha, so it passes validation but fails on saving the value to the database...

dreamsbond commented 7 years ago
.                                                                   1 / 1 (100%)string(6038) "#0 [internal function]: Limoncello\Application\Packages\Application\Application->Limoncello\Application\Packages\Application\{closure}(2, 'strlen() expect...', '/timable/limonc...', 44, Array)
#1 /timable/limoncello-php/vendor/doctrine/dbal/lib/Doctrine/DBAL/Types/ConversionException.php(44): strlen(Object(DateTimeImmutable))
#2 /timable/limoncello-php/vendor/limoncello-php/flute/src/Types/JsonApiDateTimeType.php(53): Doctrine\DBAL\Types\ConversionException::conversionFailed(Object(DateTimeImmutable), 'datetime')
#3 /timable/limoncello-php/vendor/limoncello-php/flute/src/Adapters/Repository.php(140): Limoncello\Flute\Types\JsonApiDateTimeType->convertToDatabaseValue(Object(DateTimeImmutable), Object(Doctrine\DBAL\Platforms\MySQL57Platform))
#4 /timable/limoncello-php/vendor/limoncello-php/flute/src/Api/Crud.php(320): Limoncello\Flute\Adapters\Repository->create('App\\Data\\Models...', Array)
#5 /timable/limoncello-php/app/Json/Api/SubmissionsApi.php(100): Limoncello\Flute\Api\Crud->create(NULL, Array, Array)
#6 /timable/limoncello-php/vendor/limoncello-php/flute/src/Http/BaseController.php(112): App\Json\Api\SubmissionsApi->create(NULL, Array, Array)
#7 [internal function]: Limoncello\Flute\Http\BaseController::create(Array, Object(Limoncello\Container\Container), Object(Zend\Diactoros\ServerRequest))
#8 /timable/limoncello-php/vendor/limoncello-php/core/src/Application/Application.php(285): call_user_func(Array, Array, Object(Limoncello\Container\Container), Object(Zend\Diactoros\ServerRequest))
#9 /timable/limoncello-php/vendor/limoncello-php/core/src/Application/Application.php(351): Limoncello\Core\Application\Application->callHandler(Array, Array, Object(Limoncello\Container\Container), Object(Zend\Diactoros\ServerRequest))
#10 /timable/limoncello-php/app/Json/Exceptions/ApiHandler.php(35): Limoncello\Core\Application\Application->Limoncello\Core\Application\{closure}(Object(Zend\Diactoros\ServerRequest))
#11 [internal function]: App\Json\Exceptions\ApiHandler::handle(Object(Zend\Diactoros\ServerRequest), Object(Closure), Object(Limoncello\Container\Container))
#12 /timable/limoncello-php/vendor/limoncello-php/core/src/Application/Application.php(423): call_user_func(Array, Object(Zend\Diactoros\ServerRequest), Object(Closure), Object(Limoncello\Container\Container))
#13 /timable/limoncello-php/vendor/limoncello-php/passport/src/Authentication/PassportMiddleware.php(66): Limoncello\Core\Application\Application->Limoncello\Core\Application\{closure}(Object(Zend\Diactoros\ServerRequest))
#14 [internal function]: Limoncello\Passport\Authentication\PassportMiddleware::handle(Object(Zend\Diactoros\ServerRequest), Object(Closure), Object(Limoncello\Container\Container))
#15 /timable/limoncello-php/vendor/limoncello-php/core/src/Application/Application.php(423): call_user_func(Array, Object(Zend\Diactoros\ServerRequest), Object(Closure), Object(Limoncello\Container\Container))
#16 /timable/limoncello-php/vendor/limoncello-php/application/src/Packages/Cors/CorsMiddleware.php(53): Limoncello\Core\Application\Application->Limoncello\Core\Application\{closure}(Object(Zend\Diactoros\ServerRequest))
#17 [internal function]: Limoncello\Application\Packages\Cors\CorsMiddleware::handle(Object(Zend\Diactoros\ServerRequest), Object(Closure), Object(Limoncello\Container\Container))
#18 /timable/limoncello-php/vendor/limoncello-php/core/src/Application/Application.php(423): call_user_func(Array, Object(Zend\Diactoros\ServerRequest), Object(Closure), Object(Limoncello\Container\Container))
#19 [internal function]: Limoncello\Core\Application\Application->Limoncello\Core\Application\{closure}(Object(Zend\Diactoros\ServerRequest))
#20 /timable/limoncello-php/vendor/limoncello-php/core/src/Application/Application.php(186): call_user_func(Object(Closure), Object(Zend\Diactoros\ServerRequest))
#21 /timable/limoncello-php/vendor/limoncello-php/testing/src/ApplicationWrapperTrait.php(160): Limoncello\Core\Application\Application->handleRequest(Object(Closure), Object(Zend\Diactoros\ServerRequest))
#22 /timable/limoncello-php/vendor/limoncello-php/core/src/Application/Application.php(146): class@anonymous->handleRequest(Object(Closure), Object(Zend\Diactoros\ServerRequest))
#23 /timable/limoncello-php/vendor/limoncello-php/testing/src/TestCaseTrait.php(106): Limoncello\Core\Application\Application->run()
#24 /timable/limoncello-php/vendor/limoncello-php/testing/src/JsonApiCallsTrait.php(47): Tests\TestCase->call('POST', '/api/v1/submiss...', Array, Array, Array, Array, Array, Array, Object(Zend\Diactoros\Stream))
#25 /timable/limoncello-php/tests/SubmissionApiTest.php(116): Tests\SubmissionApiTest->postJsonApi('/api/v1/submiss...', '        {\n     ...', Array)
#26 [internal function]: Tests\SubmissionApiTest->testCreate()
#27 /timable/limoncello-php/vendor/phpunit/phpunit/src/Framework/TestCase.php(1069): ReflectionMethod->invokeArgs(Object(Tests\SubmissionApiTest), Array)
#28 /timable/limoncello-php/vendor/phpunit/phpunit/src/Framework/TestCase.php(928): PHPUnit\Framework\TestCase->runTest()
#29 /timable/limoncello-php/vendor/phpunit/phpunit/src/Framework/TestResult.php(695): PHPUnit\Framework\TestCase->runBare()
#30 /timable/limoncello-php/vendor/phpunit/phpunit/src/Framework/TestCase.php(883): PHPUnit\Framework\TestResult->run(Object(Tests\SubmissionApiTest))
#31 /timable/limoncello-php/vendor/phpunit/phpunit/src/Framework/TestSuite.php(744): PHPUnit\Framework\TestCase->run(Object(PHPUnit\Framework\TestResult))
#32 /timable/limoncello-php/vendor/phpunit/phpunit/src/TextUI/TestRunner.php(537): PHPUnit\Framework\TestSuite->run(Object(PHPUnit\Framework\TestResult))
#33 /timable/limoncello-php/vendor/phpunit/phpunit/src/TextUI/Command.php(212): PHPUnit\TextUI\TestRunner->doRun(Object(PHPUnit\Framework\TestSuite), Array, true)
#34 /timable/limoncello-php/vendor/phpunit/phpunit/src/TextUI/Command.php(141): PHPUnit\TextUI\Command->run(Array, true)
#35 /timable/limoncello-php/vendor/phpunit/phpunit/phpunit(53): PHPUnit\TextUI\Command::main()
neomerx commented 7 years ago

I think it should be an error in \Limoncello\Flute\Types\JsonApiDateTimeType::convertToDatabaseValue

Can you try to replace it with the following?

    public function convertToDatabaseValue($value, AbstractPlatform $platform)
    {
        if ($value instanceof DateTimeInterface) {
            $dateTime = $value;
        } elseif ($value instanceof JsonApiDateTime) {
            $dateTime = $value->getValue();
        } elseif (is_string($value) === true) {
            if (($dateTime = DateTime::createFromFormat(DateBaseType::JSON_API_FORMAT, $value)) === false) {
                throw ConversionException::conversionFailed($value, $this->getName());
            }
        } else {
            throw ConversionException::conversionFailed($value, $this->getName());
        }

        return parent::convertToDatabaseValue($dateTime, $platform);
    }
dreamsbond commented 7 years ago

yes. replaced with your snippet and the issue gone

neomerx commented 7 years ago

as for display order try to add isNumeric check

    public static function displayOrder(): RuleInterface
    {
        return self::isNumeric(self::stringToInt());
    }
dreamsbond commented 7 years ago

i have two more concerns. for nullable text validation i used to do it like this:

    /**
     * @return RuleInterface
     */
    public static function description(): RuleInterface
    {
        return self::nullable(self::isString());
    }

in case of a integer with default value (or anything default numeric with default value)

    /**
     * @return RuleInterface
     */
    public static function displayOrder(): RuleInterface
    {
        return self::isNumeric(self::stringToInt());
    }

is it a proper way?

for hasMany relationship is there any guideline for proper validation?

neomerx commented 7 years ago

description looks fine.

If you don't use required then 2 options are fine: valid input and no input. If you use required the value has to be provided and it should be valid. So if no required used some default value in database should be used.

Built-in hasMany expect all values to have the same type. It would be complicated to support many types (and corresponding validators for ids) though possible.

hasMany usage should be simple. If you have any issues with it don't hesitate to ask.

dreamsbond commented 7 years ago
    public static function name(): RuleInterface
    {
        return self::orX(
            self::exists(Model::TABLE_NAME, Model::FIELD_NAME),
            self::stringLengthBetween(Model::MIN_NAME_LENGTH, Model::getAttributeLengths()[ Model::FIELD_NAME ])
        );
    }

and

    /**
     * @return RuleInterface
     */
    public static function uniqueName(): RuleInterface
    {
        return self::andX(
            self::unique(Model::TABLE_NAME, Model::FIELD_NAME),
            self::stringLengthBetween(Model::MIN_NAME_LENGTH, Model::getAttributeLengths()[ Model::FIELD_NAME ])
        );
    }

seems does validate properly

in case of the following input:

            $jsonInput = <<<EOT
        {
            "data" : {
                "type"  : "lessons",
                "id"    : null,
                "attributes" : {
                    "name"           : "Mathematics",
                    "description"    : "some description",
                    "display-order"  : "0" 
                }
            }
        }
EOT;

with minimum length of name in model:

    /**
     * Minimum name length
     */
    const MIN_NAME_LENGTH = '3';

and maximum length of name

    /**
     * @inheritDoc
     */
    public static function getAttributeLengths(): array
    {
        return [
            self::FIELD_NAME => 100,
        ];
    }

in Validator "LessonCreate"


    public static function getAttributeRules(): array
    {
        return [
            Scheme::ATTR_NAME          => v::required(v::uniqueName()),
            Scheme::ATTR_DESCRIPTION   => v::description(),
            Scheme::ATTR_DISPLAY_ORDER => v::displayOrder(),
        ];
    }

the test throws correctly

.                                                                   1 / 1 (100%)object(Neomerx\JsonApi\Exceptions\ErrorCollection)#517 (1) {
  ["items":"Neomerx\JsonApi\Exceptions\ErrorCollection":private]=>
  array(1) {
    [0]=>
    object(Neomerx\JsonApi\Document\Error)#518 (8) {
      ["idx":"Neomerx\JsonApi\Document\Error":private]=>
      NULL
      ["links":"Neomerx\JsonApi\Document\Error":private]=>
      NULL
      ["status":"Neomerx\JsonApi\Document\Error":private]=>
      string(3) "422"
      ["code":"Neomerx\JsonApi\Document\Error":private]=>
      NULL
      ["title":"Neomerx\JsonApi\Document\Error":private]=>
      string(21) "The value is invalid."
      ["detail":"Neomerx\JsonApi\Document\Error":private]=>
      string(40) "The value should be a unique identifier."
      ["source":"Neomerx\JsonApi\Document\Error":private]=>
      array(1) {
        ["pointer"]=>
        string(21) "/data/attributes/name"
      }
      ["meta":"Neomerx\JsonApi\Document\Error":private]=>
      NULL
    }
  }
}

as "Mathematics" already exists but in case of another lesson name which do not exists in the database, say "Geo Science"

it throws:


.                                                                   1 / 1 (100%)object(Neomerx\JsonApi\Exceptions\ErrorCollection)#517 (1) {
  ["items":"Neomerx\JsonApi\Exceptions\ErrorCollection":private]=>
  array(1) {
    [0]=>
    object(Neomerx\JsonApi\Document\Error)#518 (8) {
      ["idx":"Neomerx\JsonApi\Document\Error":private]=>
      NULL
      ["links":"Neomerx\JsonApi\Document\Error":private]=>
      NULL
      ["status":"Neomerx\JsonApi\Document\Error":private]=>
      string(3) "422"
      ["code":"Neomerx\JsonApi\Document\Error":private]=>
      NULL
      ["title":"Neomerx\JsonApi\Document\Error":private]=>
      string(21) "The value is invalid."
      ["detail":"Neomerx\JsonApi\Document\Error":private]=>
      string(49) "The value should be between 3 and 100 characters."
      ["source":"Neomerx\JsonApi\Document\Error":private]=>
      array(1) {
        ["pointer"]=>
        string(21) "/data/attributes/name"
      }
      ["meta":"Neomerx\JsonApi\Document\Error":private]=>
      NULL
    }
  }
}

i think it would be too many questions today 📦

neomerx commented 7 years ago

You've found another bug :tada:

In \Limoncello\Validation\Rules\Comparisons\StringLengthBetween::__construct line 39

        parent::__construct($min, $min, ErrorCodes::STRING_LENGTH_BETWEEN, $errorContext);

should be replaced with

        parent::__construct($min, $max, ErrorCodes::STRING_LENGTH_BETWEEN, $errorContext);
neomerx commented 7 years ago

all fixes will be published later this week

dreamsbond commented 7 years ago

i have originally a scenario on validator:

for previous version of limoncello-php/app

i used a dirty hack in BaseAppValidator;

snippets:

    /**
     * @return RuleInterface
     */
    protected function name($index = null): RuleInterface
    {
        $primaryKey = $index === null ? $index : [Model::FIELD_ID, $index];

        $maxLength = Model::getAttributeLengths()[Model::FIELD_NAME];
        $name = $this->andX(
            $this->stringLength(Model::MIN_NAME_LENGTH, $maxLength),
            $this->isString()
        );

        return $this->andX($name, $this->isUnique(Model::TABLE_NAME, Model::FIELD_NAME, false, $primaryKey));
    private function exists($tableName, $columnName, $value, $primaryKey = null): bool
    {
        /** @var Connection $connection */
        $connection = $this->getContainer()->get(Connection::class);

        if ($primaryKey === null) {
            $query = $connection->createQueryBuilder();
            $query
                ->select($columnName)
                ->from($tableName)
                ->where($columnName . '=' . $query->createPositionalParameter($value))
                ->setMaxResults(1);

            $fetched = $query->execute()->fetch();
            $result = $fetched !== false;
        } else {
            list($primaryKeyName, $primaryKeyValue) = $primaryKey;

            $query = $connection->createQueryBuilder();
            $query
                ->select("`{$primaryKeyName}`, `{$columnName}`")
                ->from($tableName)
                ->where($columnName . '=' . $query->createPositionalParameter($value))
                ->setMaxResults(1);

            $fetched = $query->execute()->fetch();
            $result = $fetched !== false &&
                $fetched[$primaryKeyName] !== $primaryKeyValue;
        }

        return $result;
    }

on 0.7.x forward, i found this hack no longer usable, as i can't find the entry point for capturing the index (id) of record.

neomerx commented 7 years ago

so for Create it works fine for you now. No probs here.

As for Update I can see a few possible ways to solve the problem.

a) Do not check uniqueness of the name on update. Intercept the captured data between Validation and API and make additional check (or add the check to API level) b) Put to app container index of the resource and add custom Validation rule that uses that value (it has an access to app container) c) Make custom validator which actually consists of 2 validators. The first one parses the input json and the second uses capture from the first one to set up rules for the second validator. This one is the most elegant and I'm working on similar issue currently. Though it's not finished yet so it's not an option at the moment.

Currently, the easiest one would be option b).

dreamsbond commented 7 years ago

i tried to do it in a custom validator like 'isUpdateUniqueRule' but finally found no where to capture the index :(.. sad

ya, i think option b would be easier for me to do

neomerx commented 7 years ago

b) is not a bad option it's just not universal. You can ping me at the end of the week I might have something for c).

I'm working on implementing validation for the publishing process. This involves different rules depending on the input data, validation of combined input data and data stored in the database. I hope something good may come out of this for more generic use.

dreamsbond commented 7 years ago

the procedure would be:

am i correct? btw, what is the convention of putting my own custom class?

dreamsbond commented 7 years ago

i mean , is there any convention on where to put my own custom class

neomerx commented 7 years ago

there are no special requirements for placing custom rules. You can put it near to custom rule example \App\Json\Validators\Rules\IsEmailRule.

dreamsbond commented 7 years ago

the option b works; though it was not really perfect at the moment. but i can really feel the power of container wise capability!!!

neomerx commented 7 years ago

@dreamsbond After some working on a bigger project I came to a conclusion that it's better to move some validator files around and structure them a bit different. It will provide a better manageability in the long run. Here is a new layout/structure. The key change to make it work in sub-folders is this line in settings that instructs to search in sub folders (note **).

Some new features in Validation: new rule enum, new rule filter (a very very powerful tool that can check emails, URLs, sanitize input, and etc). The filter rule is a wrapper over PHP filter_var and has identical settings. Also, chaining capability was added to all applicable rules.

As for custom validation (option c)) I've got some good news as well. In Controller you can replace default Validator with a custom one which will add extra validation and will be able to use captured data.

    protected static function createOnCreateValidator(ContainerInterface $container): JsonApiValidatorInterface
    {
        $wrapper = new CustomOnCreateValidator (parent::createOnCreateValidator($container));

        return $wrapper;
    }

    protected static function createOnUpdateValidator(ContainerInterface $container): JsonApiValidatorInterface
    {
        $wrapper = new CustomOnUpdateValidator (parent::createOnUpdateValidator($container));

        return $wrapper;
    }

An example of custom validator wrapper could be seen here.