diff --git a/framework/data/ModelSerializer.php b/framework/data/ModelSerializer.php new file mode 100644 index 0000000000..844dd3f77e --- /dev/null +++ b/framework/data/ModelSerializer.php @@ -0,0 +1,335 @@ + + * @since 2.0 + */ +class ModelSerializer extends Component +{ + /** + * @var string the model class that this API is serving. If not set, it will be initialized + * as the class of the model(s) being exported by [[export()]] or [[exportAll()]]. + */ + public $modelClass; + /** + * @var mixed the context information. If not set, it will be initialized as the "user" application component. + * You can use the context information to conditionally control which fields can be returned for a model. + */ + public $context; + /** + * @var array|string an array or a string of comma separated field names representing + * which fields should be returned. Only fields declared in [[fields()]] will be respected. + * If this property is empty, all fields declared in [[fields()]] will be returned. + */ + public $fields; + /** + * @var array|string an array or a string of comma separated field names representing + * which fields should be returned in addition to those declared in [[fields()]]. + * Only fields declared in [[expand()]] will be respected. + */ + public $expand; + /** + * @var integer the error code to be used in the result of [[exportErrors()]]. + */ + public $validationErrorCode = 1024; + /** + * @var string the error message to be used in the result of [[exportErrors()]]. + */ + public $validationErrorMessage = 'Validation Failed'; + /** + * @var array a list of serializer classes indexed by their corresponding model classes. + * This property is used by [[createSerializer()]] to serialize embedded objects. + * @see createSerializer() + */ + public $serializers = []; + /** + * @var array a list of paths or path aliases specifying how to look for a serializer class + * given a model class. If the base name of a model class is `Xyz`, the corresponding + * serializer class being looked for would be `XyzSerializer` under each of the paths listed here. + */ + public $serializerPaths = ['@app/serializers']; + /** + * @var array the loaded serializer objects indexed by the model class names + */ + private $_serializers = []; + + + /** + * @inheritdoc + */ + public function init() + { + parent::init(); + if ($this->context === null && Yii::$app) { + $this->context = Yii::$app->user; + } + } + + /** + * Exports a model object by converting it into an array based on the specified fields. + * @param Model $model the model being exported + * @return array the exported data + */ + public function export($model) + { + if ($this->modelClass === null) { + $this->modelClass = get_class($model); + } + + $fields = $this->resolveFields($this->fields, $this->expand); + return $this->exportObject($model, $fields); + } + + /** + * Exports an array of model objects by converting it into an array based on the specified fields. + * @param Model[] $models the models being exported + * @return array the exported data + */ + public function exportAll(array $models) + { + if (empty($models)) { + return []; + } + + if ($this->modelClass === null) { + $this->modelClass = get_class(reset($models)); + } + + $fields = $this->resolveFields($this->fields, $this->expand); + $result = []; + foreach ($models as $model) { + $result[] = $this->exportObject($model, $fields); + } + return $result; + } + + /** + * Exports the model validation errors. + * @param Model $model + * @return array + */ + public function exportErrors($model) + { + $result = [ + 'code' => $this->validationErrorCode, + 'message' => $this->validationErrorMessage, + 'errors' => [], + ]; + foreach ($model->getFirstErrors() as $name => $message) { + $result['errors'][] = [ + 'field' => $name, + 'message' => $message, + ]; + } + return $result; + } + + /** + * Returns a list of fields that can be returned to end users. + * + * These are the fields that should be returned by default when a user does not explicitly specify which + * fields to return for a model. If the user explicitly which fields to return, only the fields declared + * in this method can be returned. All other fields will be ignored. + * + * By default, this method returns [[Model::attributes()]], which are the attributes defined by a model. + * + * You may override this method to select which fields can be returned or define new fields based + * on model attributes. + * + * The value returned by this method should be an array of field definitions. The array keys + * are the field names, and the array values are the corresponding attribute names or callbacks + * returning field values. If a field name is the same as the corresponding attribute name, + * you can use the field name without a key. + * + * @return array field name => attribute name or definition + */ + protected function fields() + { + if (is_subclass_of($this->modelClass, Model::className())) { + /** @var Model $model */ + $model = new $this->modelClass; + return $model->attributes(); + } else { + return array_keys(get_class_vars($this->modelClass)); + } + } + + /** + * Returns a list of additional fields that can be returned to end users. + * + * The default implementation returns an empty array. You may override this method to return + * a list of additional fields that can be returned to end users. Please refer to [[fields()]] + * on the format of the return value. + * + * You usually override this method by returning a list of relation names. + * + * @return array field name => attribute name or definition + */ + protected function expand() + { + return []; + } + + /** + * Filters the data to be exported to end user. + * The default implementation does nothing. You may override this method to remove + * certain fields from the data being exported based on the [[context]] information. + * You may also use this method to add some common fields, such as class name, to the data. + * @param array $data the data being exported + * @return array the filtered data + */ + protected function filter($data) + { + return $data; + } + + /** + * Returns the serializer for the specified model class. + * @param string $modelClass fully qualified model class name + * @return static the serializer + */ + protected function getSerializer($modelClass) + { + if (!isset($this->_serializers[$modelClass])) { + $this->_serializers[$modelClass] = $this->createSerializer($modelClass); + } + return $this->_serializers[$modelClass]; + } + + /** + * Creates a serializer object for the specified model class. + * + * This method tries to create an appropriate serializer using the following algorithm: + * + * - Check if [[serializers]] specifies the serializer class for the model class and + * create an instance of it if available; + * - Search for a class named `XyzSerializer` under the paths specified by [[serializerPaths]], + * where `Xyz` stands for the model class. + * - If both of the above two strategies fail, simply return an instance of `ModelSerializer`. + * + * @param string $modelClass the model class + * @return ModelSerializer the new model serializer + */ + protected function createSerializer($modelClass) + { + if (isset($this->serializers[$modelClass])) { + $config = $this->serializers[$modelClass]; + if (!is_array($config)) { + $config = ['class' => $config]; + } + } else { + $className = StringHelper::basename($modelClass) . 'Serializer'; + foreach ($this->serializerPaths as $path) { + $path = Yii::getAlias($path); + if (is_file($path . "/$className.php")) { + $config = ['class' => $className]; + break; + } + } + } + + if (!isset($config)) { + $config = ['class' => __CLASS__]; + } + $config['modelClass'] = $modelClass; + $config['context'] = $this->context; + + return Yii::createObject($config); + } + + /** + * Returns the fields of the model that need to be returned to end user + * @param string|array $fields an array or a string of comma separated field names representing + * which fields should be returned. + * @param string|array $expand an array or a string of comma separated field names representing + * which additional fields should be returned. + * @return array field name => field definition (attribute name or callback) + */ + protected function resolveFields($fields, $expand) + { + if (!is_array($fields)) { + $fields = preg_split('/\s*,\s*/', $fields, -1, PREG_SPLIT_NO_EMPTY); + } + if (!is_array($expand)) { + $expand = preg_split('/\s*,\s*/', $expand, -1, PREG_SPLIT_NO_EMPTY); + } + + $result = []; + + foreach ($this->fields() as $field => $definition) { + if (is_integer($field)) { + $field = $definition; + } + if (empty($fields) || in_array($field, $fields, true)) { + $result[$field] = $definition; + } + } + + if (empty($expand)) { + return $result; + } + + foreach ($this->expand() as $field => $definition) { + if (is_integer($field)) { + $field = $definition; + } + if (in_array($field, $expand, true)) { + $result[$field] = $definition; + } + } + + return $result; + } + + /** + * Exports an object by converting it into an array based on the given field definitions. + * @param object $model the model being exported + * @param array $fields field definitions (field name => field definition) + * @return array the exported model data + */ + protected function exportObject($model, $fields) + { + $data = []; + foreach ($fields as $field => $attribute) { + if (is_string($attribute)) { + $value = $model->$attribute; + } else { + $value = call_user_func($attribute, $model, $field); + } + if (is_object($value)) { + $value = $this->getSerializer(get_class($value))->export($value); + } elseif (is_array($value)) { + foreach ($value as $i => $v) { + if (is_object($v)) { + $value[$i] = $this->getSerializer(get_class($v))->export($v); + } + // todo: array of array + } + } + $data[$field] = $value; + } + return $this->filter($data); + } +}