POSTValidator.php

<?php

namespace Lia\Addon;

/**
 * When activated, it unsets `$_POST` and then only re-sets it after validation
 */
class POSTValidator extends \Lia\Addon {

    public string $fqn = 'lia:server.postvalidator';

    public readonly \Lia\Utility\InputValidator $validator;

    /**
     * @key the POST key. i.e. `$_POST['key']`
     * @value the list of rules that failed. See InputValidator::$failed_rules
     */
    public array $failures = [];

    /**
     * `$_POST` is copied to here
     */
    protected array $post;

    /**
     * Failure messages retrieved from the session.
     */
    protected ?array $session_failure_messages;

    /** true if any validation has failed */
    protected bool $validation_failed = false;

    /**
     * Enable for the given instance of liaison. Sets `$_POST = []`. Re-populates `$_POST` when validations are successful.
     */
    static public function activate(\Lia $lia){
        $instance = static::from($lia);
        $instance->post = $_POST;
        $_POST = [];
    }

    /**
     * Hooks into \Lia\Hooks::ROUTES_FILTERED, to activate only if the given package is requested by the route.
     */
    static public function activateForPackage(\Lia\Package $package){
        \Lia\Addon\Hook::from($package->lia)
            ->add(\Lia\Hooks::ROUTES_FILTERED,
                function(\Lia\Obj\Route $route) use ($package): void {
                    if ($route->package() != $package)return;
                    static::activate($package->lia);
                }
        );
    }

    static public function from(\Lia $lia): self|static {
		return parent::from($lia);
	}


    public function __construct(?\Lia\Package $package=null){
        parent::__construct($package);
        $this->validator = new \Lia\Utility\InputValidator();
    }

    /** return true if any validation failed on the current request */
    public function validation_failed(): bool {
        return $this->validation_failed;
    }

    /**
     * Print all $_POST values and `exit`
     */
    public function dump(){
        print_r($this->post);
        exit;
    }


    /**
     * Check if the given entry matches expectations. You may pass BOTH `$rules` AND `$validation_function`, or you may pass just one (*your choice*). If you pass both, then both `$rules` AND `$validation_function` MUST return `TRUE` for validation to pass. If you pass only `$key`, then it is considered valid as long as `$key` is set in `$_POST`. When validation passes, `$_POST[$key]` is populated with the validated value.
     * 
     * @param string $key the `$_POST['key']`
     * @param array $rules Rules to validate using the built-in validator class.
     * @param callable $validation_function a user-defined `function(string $key, mixed $value): bool` that returns TRUE (*valid*) or FALSE (*invalid*)
     * @return bool TRUE (valid) or FALSE (invalid)
     */
    public function validate(string $key, array|string|null $rules = null, ?callable $validation_function = null): bool {
        if (!isset($this->post)){
            throw new \Lia\Exception(\Lia\Exception::VALIDATOR_NOT_ACTIVE);
        }
        if (!isset($this->post[$key])){
            // TODO: Report that value was not submitted
            // NOTE: Perhaps we need a 'required' rule 
            return false;
        }

        $is_valid = true;

        if ($rules != null && $rules != []){
            $is_valid = $this->validator->validate($rules, $this->post[$key], null, $this->post[$key]);
        }

        if ($is_valid && $validation_function != null){
            $is_valid = call_user_func($validation_function, $key, $this->post[$key]);
        }

        if (!$is_valid){

            $this->failures[$key] = $this->validator->failed_rules;
            $this->validation_failed = true;
            return false;
        }

        $_POST[$key] = $this->post[$key];

        return true;
    }

    /** 
     * Get array of failure messages determined during this request
     *
     * @return array<string POST_key, array failmsgs> where failmsgs is array<int index, string message> 
     */
    public function get_failure_messages(): array {
        if (isset($this->session_failure_messages)){
            return $this->session_failure_messages;
        }

        // TODO: we can only get fail messages of rules that are built-in to InputValidator. Non-builtins will cause an exception to be thrown by get_failure_message(). We need ... some kind of solution.

        $msgs = [];
        foreach ($this->failures as $post_key => $rules_failed){
            $msgs[$post_key] = [];
            foreach ($rules_failed as $index => $rule){
                $rule_name = $rule[0];
                $rule_options = $rule[1];
                $rule_msg = $this->validator->get_failure_message($rule_name, $rule_options);
                $msgs[$post_key][] = $rule_msg;
            }
        }

        return $msgs;
    }

    /**
     * Store failure messages in `$_SESSION` to be retrieved by and displayed on a form after redirect. Silently fails if sessions are disabled.
     */
    public function store_failure_messages(){
        if (session_status() == PHP_SESSION_NONE){
            session_start();
        }

        $msgs = $this->get_failure_messages();
        $_SESSION['postvalidator-failure-messages'] = $msgs;
    }

    /**
     * If there are failure messages stored in the session, they will be retrieved, and then removed from the session. If none are stored, nothing happens (*no errors*)
     *
     */
    public function load_failure_messages_from_session(){
        if (session_status() == PHP_SESSION_NONE){
            session_start();
        }
        if (!isset($_SESSION['postvalidator-failure-messages'])){
            return;
        }
        $this->session_failure_messages = $_SESSION['postvalidator-failure-messages'];

        unset($_SESSION['postvalidator-failure-messages']);
        return $this->session_failure_messages;
    }

    /**
     * Get the first failure message for the given POST key
     *
     * @return empty string if there is no failure. Return generic string if there was a failure but no defined message.
     */
    public function get_failure_message(string $for_key): string {
        if (!isset($this->failures[$for_key])
            && !isset($this->session_failure_messages[$for_key])
            )return '';

        $messages = $this->get_failure_messages();


        if (!isset($messages[$for_key])
            ||count($messages[$for_key]) == 0) return 'Invalid input';

        return $messages[$for_key][0];
    }
}