phprestyii2yii2-modelyii2-validation

What is a recommended design pattern for processing data from an API response in Yii2?


My Yii2 application (in PHP 7, so unfortunately I do not have strict types) receives from an API call responses which should be nested arrays looking like:

$response = [
  'session' => [
     'userData' => ['user_id' => 232, ...],
     'sessionData => ['status' => 'ONGOING', 'details' => 'Some details here...', ...],
     'additionalData' => [...]
  ]
]

Currently in my application logic, when I receive the response I manually check that each field exists and is of the required type. So for example I start by checking that $response['session'] exists and is an array, and I throw an exception if it doesn't, so something like:

if (!array_key_exists($response, 'session')) {
    throw new Exception('Invalid response: missing "session" key in response array.');
}
if (!is_array($response['session'])) {
   throw new Exception('Invalid response: "session" is not an array in response array.');
}

Then I check for $response['session']['userData'] and I check that it's an array, and so on. When I am certain that $response['session']['userData']['user_id'] exists, I also check that it is an integer, so I also do validation for the primitive received types.

I would like to decouple this logic from my main application, by having a separate model which stores all the primitive properties I want from the response and validates the primitive ones.

My problem is that I'm uncertain what would be the best design pattern to implement this.

My initial thought was to build a DTO-Hydrator-like pattern, so:

  1. A model (DTO) having as fields only the 'primitive' properties such as user_id, status, details, etc, but not the arrays like session, userData. This model would only be responsible for validation of the primitive fields (so checking that user_id is an integer for instance, checking that 'status' is in an array of admissible statuses, etc.)
  2. A Hydrator model which is responsible for checking that all desired primitive fields exist, and of course, that everything 'in-between' exists, and then loading the primitive fields into the DTO.

However, this approach seems overly complicated. For one thing, I would have to instantiate both the DTO and the Hydrator each time I have to handle a response in my application logic, which would still make that logic too cumbersome for what I want. Ideally I would like to only do something like $model->process($response) in my application logic, but in a way that is also an endorsed design pattern. So is there a way to do this without the (seeming) overcomplication of the DTO-Hydrator pattern?


Solution

  • So, with the restraints of using Yii2 and default components and given I am not a usual Yii user this is probably how I would approach it. There is probably a simpler way. This thought was spurred by @Leroy's suggestion in the comment.

    Couple of caveats. I am running yii2 on php 8.3 and I'm so used to typing everything just remove that if need be. This is a fresh install of yii2 basic.

    If I had to put a pattern label on this I would say its approximately the proxy pattern iirc.

    In your question you gave a preferred signature of:

    $model->process($response)
    

    This example differs from that slightly. To populate your Model all that is required is passing its constructor the data. I commented the code to provide some info about what the class properties are used for such as $targetKeys etc.

    Given incoming sample data for testing with this shape:

        // Sample data for testing
        private $sampleData = [
            'session' => [
                'userData' => [
                    'user_id'    => 232,
                    'user_name'  => 'John Doe',
                    'user_email' => 'jdoe@example.com',
                ],
                'additionalData' => [
                    'some_key1' => 'some_value1',
                    'some_key2' => 'some_value2',
                    'some_key3' => 3,
                ],
            ],
        ];
    

    I'm just using the default SiteController here to get into the workflow.

        public function actionIndex()
        {
            // Example usage of ModelProxy
            $model = new ModelProxy($this->sampleData);
    
            return $this->render('index');
        }
    

    models/ModelProxy.php

    <?php
    
    declare(strict_types=1);
    
    namespace app\models;
    
    use yii\base\Model;
    use yii\helpers\ArrayHelper;
    
    class ModelProxy
    {
        private array $data;
        private Model $model;
        private string $modelClass = ProxiedModel::class;
    
        public function __construct(
            array $data,
        ) {
            $this->data = $data;
    
            $this->model = new $this->modelClass();
            $targetKeys  = $this->model->getTargetKeys();
            $modelData   = [];
    
            foreach ($targetKeys as $key) {
                $modelData[] = ArrayHelper::getValue($this->data, $key);
            }
    
            foreach ($modelData as $key => $value) {
                if (ArrayHelper::isAssociative($value)) {
                    foreach ($value as $attribute => $attributeValue) {
                        $this->model->setAttributes([$attribute => $attributeValue]);
                        continue;
                    }
                }
            }
    
            if (! $this->model->validate()) {
                throw new \Exception('Model validation failed: ' . json_encode($this->model->getErrors()));
            }
        }
    
        public function validate(): bool
        {
            return $this->model->validate();
        }
    }
    

    models/ProxiedModel.php

    <?php
    
    declare(strict_types=1);
    
    namespace app\models;
    
    use yii\base\Model;
    
    class ProxiedModel extends Model
    {
        // Sample attributes for the model
        public $user_id;
        public $user_name;
        public $user_email;
        public $some_key1;
        public $some_key2;
        public $some_key3;
    
        // Target keys to be used to extract data from the session
        // and set to the model attributes
        private array $targetKeys = [
            'session.userData',
            'session.additionalData',
        ];
    
        public function getTargetKeys(): array
        {
            return $this->targetKeys;
        }
    
        public function rules(): array
        {
            return [
                [['user_id', 'user_name', 'user_email'], 'required'],
                [['user_id'], 'integer'],
                [['user_name', 'user_email', 'some_key1', 'some_key2'], 'string'],
                [['some_key3'], 'integer'],
            ];
        }
    
        public function attributeLabels(): array
        {
            return [
                'user_id'    => 'User ID',
                'user_name'  => 'User Name',
                'user_email' => 'User Email',
                'some_key1'  => 'Some Key 1',
                'some_key2'  => 'Some Key 2',
                'some_key3'  => 'Some Key 3',
            ];
        }
    
        public function attributes(): array
        {
            return [
                'user_id',
                'user_name',
                'user_email',
                'some_key1',
                'some_key2',
            ];
        }
    }
    

    As you can see if the validation fails we throw an exception and pass the errors as json.

    Couple of things to be aware of. Passing a string to the integer validator will pass if its '3'. Please check the validator docs for details on all of the default validation rules etc etc.

    Once populated the ProxiedModel will behave as all other Yii models behave since it is a Model instance. You could also proxy via the ModelProxy's classes magic methods to return the properties of the proxied model as well as its methods for example via __call etc. Those design decisions are better left to you since you know exactly what you need and I would only be guessing. I prefer to keep it strictly to what is required and expose as little of the proxied instance as possible.

    Really, how you would set this up is up to you. This is just an example of how I would do it and as I said there is probably a better way that I am overlooking since I do not use Yii. I'm a laminas/mezzio user myself but maybe it will spur some thought and get you to thinking about using the tools Yii provides in creative ways to prevent reinventing the wheel when there is no real need to add complexity.