<?php
/**
* @file classes/services/PKPSchemaService.php
*
* Copyright (c) 2014-2021 Simon Fraser University
* Copyright (c) 2000-2021 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class PKPSchemaService
*
* @ingroup services
*
* @brief Helper class for loading schemas, using them to sanitize and
* validate objects, and installing default data.
*/
namespace PKP\services;
use Exception;
use Illuminate\Support\Arr;
use Illuminate\Support\MessageBag;
use PKP\core\DataObject;
use PKP\plugins\Hook;
/**
* @template T of DataObject
*/
class PKPSchemaService
{
public const SCHEMA_ANNOUNCEMENT = 'announcement';
public const SCHEMA_AUTHOR = 'author';
public const SCHEMA_CATEGORY = 'category';
public const SCHEMA_CONTEXT = 'context';
public const SCHEMA_DOI = 'doi';
public const SCHEMA_DECISION = 'decision';
public const SCHEMA_EMAIL_TEMPLATE = 'emailTemplate';
public const SCHEMA_GALLEY = 'galley';
public const SCHEMA_HIGHLIGHT = 'highlight';
public const SCHEMA_INSTITUTION = 'institution';
public const SCHEMA_ISSUE = 'issue';
public const SCHEMA_PUBLICATION = 'publication';
public const SCHEMA_REVIEW_ASSIGNMENT = 'reviewAssignment';
public const SCHEMA_REVIEW_ROUND = 'reviewRound';
public const SCHEMA_SECTION = 'section';
public const SCHEMA_SITE = 'site';
public const SCHEMA_SUBMISSION = 'submission';
public const SCHEMA_SUBMISSION_FILE = 'submissionFile';
public const SCHEMA_USER = 'user';
public const SCHEMA_USER_GROUP = 'userGroup';
public const SCHEMA_EVENT_LOG = 'eventLog';
/** @var array cache of schemas that have been loaded */
private $_schemas = [];
/**
* Get a schema
*
* - Loads the schema file and transforms it into an object
* - Passes schema through hook
* - Returns pre-loaded schemas on request
*
* @param string $schemaName One of the SCHEMA_... constants
* @param bool $forceReload Optional. Compile the schema again from the
* source files, bypassing any cached version.
*
* @return object
*/
public function get($schemaName, $forceReload = false)
{
if (!$forceReload && array_key_exists($schemaName, $this->_schemas)) {
return $this->_schemas[$schemaName];
}
$schemaFile = sprintf('%s/lib/pkp/schemas/%s.json', BASE_SYS_DIR, $schemaName);
if (file_exists($schemaFile)) {
$schema = json_decode(file_get_contents($schemaFile));
if (!$schema) {
throw new Exception('Schema failed to decode. This usually means it is invalid JSON. Requested: ' . $schemaFile . '. Last JSON error: ' . json_last_error());
}
} else {
// allow plugins to create a custom schema and load it via hook
$schema = new \stdClass();
}
// Merge an app-specific schema file if it exists
$appSchemaFile = sprintf('%s/schemas/%s.json', BASE_SYS_DIR, $schemaName);
if (file_exists($appSchemaFile)) {
$appSchema = json_decode(file_get_contents($appSchemaFile));
if (!$appSchema) {
throw new Exception('Schema failed to decode. This usually means it is invalid JSON. Requested: ' . $appSchemaFile . '. Last JSON error: ' . json_last_error());
}
$schema = $this->merge($schema, $appSchema);
}
Hook::call('Schema::get::' . $schemaName, [&$schema]);
$this->_schemas[$schemaName] = $schema;
return $schema;
}
/**
* Merge two schemas
*
* Merges the properties of two schemas, updating the title, description,
* and properties definitions.
*
* If both schemas contain definitions for the same property, the property
* definition in the additional schema will override the base schema.
*
* @param object $baseSchema The base schema
* @param object $additionalSchema The additional schema properties to apply
* to $baseSchema.
*
* @return object
*/
public function merge($baseSchema, $additionalSchema)
{
$newSchema = clone $baseSchema;
if (!empty($additionalSchema->title)) {
$newSchema->title = $additionalSchema->title;
}
if (!empty($additionalSchema->description)) {
$newSchema->description = $additionalSchema->description;
}
if (!empty($additionalSchema->required)) {
$required = property_exists($baseSchema, 'required')
? $baseSchema->required
: [];
$newSchema->required = array_unique(array_merge($required, $additionalSchema->required));
}
if (!empty($additionalSchema->properties)) {
if (empty($newSchema->properties)) {
$newSchema->properties = new \stdClass();
}
foreach ($additionalSchema->properties as $propName => $propSchema) {
$newSchema->properties->{$propName} = $propSchema;
}
}
return $newSchema;
}
/**
* Get the summary properties of a schema
*
* Gets the properties of a schema which are considered part of the summary
* view presented in an API.
*
* @param string $schemaName One of the SCHEMA_... constants
*
* @return array List of property names
*/
public function getSummaryProps($schemaName)
{
$schema = $this->get($schemaName);
$props = [];
foreach ($schema->properties as $propName => $propSchema) {
if (!empty($propSchema->apiSummary) && empty($propSchema->writeOnly)) {
$props[] = $propName;
}
}
return $props;
}
/**
* Get all properties of a schema
*
* Gets the complete list of properties of a schema which are considered part
* of the full view presented in an API.
*
* @param string $schemaName One of the SCHEMA_... constants
*
* @return array List of property names
*/
public function getFullProps($schemaName)
{
$schema = $this->get($schemaName);
$propNames = [];
foreach ($schema->properties as $propName => $propSchema) {
if (empty($propSchema->writeOnly)) {
$propNames[] = $propName;
}
}
return $propNames;
}
/**
* Get required properties of a schema
*
* @param string $schemaName One of the SCHEMA_... constants
*
* @return array List of property names
*/
public function getRequiredProps($schemaName)
{
$schema = $this->get($schemaName);
if (!empty($schema->required)) {
return $schema->required;
}
return [];
}
/**
* Get multilingual properties of a schema
*
* @param string $schemaName One of the SCHEMA_... constants
*
* @return array List of property names
*/
public function getMultilingualProps($schemaName)
{
$schema = $this->get($schemaName);
$multilingualProps = [];
foreach ($schema->properties as $propName => $propSchema) {
if (!empty($propSchema->multilingual)) {
$multilingualProps[] = $propName;
}
}
return $multilingualProps;
}
/**
* Sanitize properties according to a schema
*
* This method coerces properties to their appropriate type, and strips out
* properties that are not specified in the schema.
*
* @param string $schemaName One of the SCHEMA_... constants
* @param array $props Properties to be sanitized
*
* @return array The sanitized props
*/
public function sanitize($schemaName, $props)
{
$schema = $this->get($schemaName);
$cleanProps = [];
foreach ($props as $propName => $propValue) {
if (empty($schema->properties->{$propName})
|| empty($schema->properties->{$propName}->type)
|| !empty($schema->properties->{$propName}->readOnly)) {
continue;
}
$propSchema = $schema->properties->{$propName};
if (!empty($propSchema->multilingual)) {
$values = [];
foreach ((array) $propValue as $localeKey => $localeValue) {
$values[$localeKey] = $this->coerce($localeValue, $propSchema->type, $propSchema);
}
if (!empty($values)) {
$cleanProps[$propName] = $values;
}
} else {
$cleanProps[$propName] = $this->coerce($propValue, $propSchema->type, $propSchema);
}
}
return $cleanProps;
}
/**
* Coerce a value to a variable type
*
* It will leave null values alone.
*
* @param string $type boolean, integer, number, string, array, object
* @param object $schema A schema defining this property
*
* @return mixed The value coerced to type
*/
public function coerce($value, $type, $schema)
{
if (is_null($value)) {
return $value;
}
switch ($type) {
case 'boolean':
return (bool) $value;
case 'integer':
return (int) $value;
case 'number':
return (float) $value;
case 'string':
if (is_object($value) || is_array($value)) {
$value = serialize($value);
}
return (string) $value;
case 'array':
$newArray = [];
if (is_array($schema->items)) {
foreach ($schema->items as $i => $itemSchema) {
$newArray[$i] = $this->coerce($value[$i], $itemSchema->type, $itemSchema);
}
} elseif (is_array($value)) {
foreach ($value as $i => $v) {
$newArray[$i] = $this->coerce($v, $schema->items->type, $schema->items);
}
} else {
$newArray[] = serialize($value);
}
return $newArray;
case 'object':
$newObject = []; // we handle JSON objects as assoc arrays in PHP
foreach ($schema->properties as $propName => $propSchema) {
if (!isset($value[$propName]) || !empty($propSchema->readOnly)) {
continue;
}
$newObject[$propName] = $this->coerce($value[$propName], $propSchema->type, $propSchema);
}
return $newObject;
}
throw new Exception('Requested variable coercion for a type that was not recognized: ' . $type);
}
/**
* Get the validation rules for the properties of a schema
*
* These validation rules are returned in a format that is ready to be passed
* into ValidatorFactory::make().
*
* @param string $schemaName One of the SCHEMA_... constants
* @param array $allowedLocales List of allowed locale keys.
*
* @return array List of validation rules for each property
*/
public function getValidationRules($schemaName, $allowedLocales)
{
$schema = $this->get($schemaName);
$rules = [];
foreach ($schema->properties as $propName => $propSchema) {
if (!empty($propSchema->multilingual)) {
foreach ($allowedLocales as $localeKey) {
$rules = $this->addPropValidationRules($rules, $propName . '.' . $localeKey, $propSchema);
}
} else {
$rules = $this->addPropValidationRules($rules, $propName, $propSchema);
}
}
return $rules;
}
/**
* Compile the validation rules for a single property's schema
*
* @param object $propSchema The property schema
*
* @return array List of Laravel-formatted validation rules
*/
public function addPropValidationRules($rules, $ruleKey, $propSchema)
{
if (!empty($propSchema->readOnly)) {
return $rules;
}
switch ($propSchema->type) {
case 'boolean':
case 'integer':
case 'numeric':
case 'string':
$rules[$ruleKey] = [$propSchema->type];
if (!empty($propSchema->validation)) {
$rules[$ruleKey] = array_merge($rules[$ruleKey], $propSchema->validation);
}
break;
case 'array':
if ($propSchema->items->type === 'object') {
$rules = $this->addPropValidationRules($rules, $ruleKey . '.*', $propSchema->items);
} else {
$rules[$ruleKey] = ['array'];
if (!empty($propSchema->validation)) {
$rules[$ruleKey] = array_merge($rules[$ruleKey], $propSchema->validation);
}
$rules[$ruleKey . '.*'] = [$propSchema->items->type];
if (!empty($propSchema->items->validation)) {
$rules[$ruleKey . '.*'] = array_merge($rules[$ruleKey . '.*'], $propSchema->items->validation);
}
}
break;
case 'object':
foreach ($propSchema->properties ?? [] as $subPropName => $subPropSchema) {
$rules = $this->addPropValidationRules($rules, $ruleKey . '.' . $subPropName, $subPropSchema);
}
break;
}
return $rules;
}
/**
* Format validation errors
*
* This method receives a (Laravel) MessageBag object and formats an error
* array to match the entity's schema. It converts Laravel's dot notation for
* objects and arrays:
*
* [
* foo.en: ['Error message'],
* foo.fr_CA: ['Error message'],
* bar.0.baz: ['Error message'],
* ]
*
* Into an assoc array, collapsing subproperty errors into their parent prop:
*
* [
* foo: [
* en: ['Error message'],
* fr_CA: ['Error message'],
* ],
* bar: ['Error message'],
* ]
*/
public function formatValidationErrors(MessageBag $errorBag): array
{
$formatted = [];
foreach ($errorBag->getMessages() as $ruleKey => $messages) {
Arr::set($formatted, $ruleKey, $messages);
}
return $formatted;
}
/**
* Set default values for an object
*
* Get default values from an object's schema and set them for the passed
* object.
*
* localeParams are used to populate translation strings where default values
* rely on them. For example, a locale string like the following:
*
* "This email was sent on behalf of {$contextName}."
*
* Will expect a $localeParams value like this:
*
* ['contextName' => 'Journal of Public Knowledge']
*
* @param string $schemaName One of the SCHEMA_... constants
* @param T $object The object to be modified
* @param array $supportedLocales List of locale keys that should receive
* default content. Example: ['en', 'fr_CA']
* @param string $primaryLocale Example: `en`
* @param array $localeParams Key/value params for the translation strings
*
* @return T
*/
public function setDefaults($schemaName, $object, $supportedLocales, $primaryLocale, $localeParams = [])
{
$schema = $this->get($schemaName);
foreach ($schema->properties as $propName => $propSchema) {
// Don't override existing values
if (!is_null($object->getData($propName))) {
continue;
}
if (!property_exists($propSchema, 'default') && !property_exists($propSchema, 'defaultLocaleKey')) {
continue;
}
if (!empty($propSchema->multilingual)) {
$value = [];
foreach ($supportedLocales as $localeKey) {
$value[$localeKey] = $this->getDefault($propSchema, $localeParams, $localeKey);
}
} else {
$value = $this->getDefault($propSchema, $localeParams, $primaryLocale);
}
$object->setData($propName, $value);
}
return $object;
}
/**
* Get the default values for a specific locale
*
* @param string $schemaName One of the SCHEMA_... constants
* @param string $locale The locale key to get values for. Example: `en`
* @param array $localeParams Key/value params for the translation strings
*
* @return array Key/value of property defaults for the specified locale
*/
public function getLocaleDefaults($schemaName, $locale, $localeParams)
{
$schema = $this->get($schemaName);
$defaults = [];
foreach ($schema->properties as $propName => $propSchema) {
if (empty($propSchema->multilingual) || empty($propSchema->defaultLocaleKey)) {
continue;
}
$defaults[$propName] = $this->getDefault($propSchema, $localeParams, $locale);
}
return $defaults;
}
/**
* Get a default value for a property based on the schema
*
* @param object $propSchema The schema definition for this property
* @param array|null $localeParams Optional. Key/value params for the translation strings
* @param string|null $localeKey Optional. The locale to translate into
*
* @return mixed Will return null if no default value is available
*/
public function getDefault($propSchema, $localeParams = null, $localeKey = null)
{
$localeParams ??= [];
switch ($propSchema->type) {
case 'boolean':
case 'integer':
case 'number':
case 'string':
if (property_exists($propSchema, 'default')) {
return $propSchema->default;
} elseif (property_exists($propSchema, 'defaultLocaleKey')) {
return __($propSchema->defaultLocaleKey, $localeParams, $localeKey);
}
break;
case 'array':
$value = [];
foreach ($propSchema->default as $default) {
$itemSchema = $propSchema->items;
$itemSchema->default = $default;
$value[] = $this->getDefault($itemSchema, $localeParams, $localeKey);
}
return $value;
case 'object':
$value = [];
foreach ($propSchema->properties ?? [] as $subPropName => $subPropSchema) {
if (!property_exists($propSchema->default, $subPropName)) {
continue;
}
$defaultSubProp = $propSchema->default->{$subPropName};
// If a prop is expected to be a string but the default value is an
// object with a `defaultLocaleKey` property, then we render that
// translation. Otherwise, we assign the values as-is and do not
// recursively check for nested objects/arrays inside of objects.
if ($subPropSchema->type === 'string' && is_object($defaultSubProp) && property_exists($defaultSubProp, 'defaultLocaleKey')) {
$value[$subPropName] = __($defaultSubProp->defaultLocaleKey, $localeParams, $localeKey);
} else {
$value[$subPropName] = $defaultSubProp;
}
}
return $value;
}
return null;
}
/**
* Add multilingual props for missing values
*
* This method will take a set of property values and add empty entries for
* any locales that are missing. Given the following:
*
* $values = [
* 'title' => [
* 'en' => 'The Journal of Public Knowledge',
* ]
* ]
*
* If the locales en and fr_CA are requested, it will return the following:
*
* $values = [
* 'title' => [
* 'en' => 'The Journal of Public Knowledge',
* 'fr_CA' => '',
* ]
* ]
*
* This is primarily used to ensure API responses present a consistent data
* structure regardless of which properties have values.
*
* @param string $schemaName One of the SCHEMA_... constants
* @param array $values Key/value list of entity properties
*
* @return array
*/
public function addMissingMultilingualValues($schemaName, $values, $localeKeys)
{
$schema = $this->get($schemaName);
$multilingualProps = $this->getMultilingualProps($schemaName);
foreach ($values as $key => $value) {
if (!in_array($key, $multilingualProps)) {
continue;
}
foreach ($localeKeys as $localeKey) {
if (is_array($value) && array_key_exists($localeKey, $value)) {
continue;
}
switch ($schema->properties->{$key}->type) {
case 'string':
$values[$key][$localeKey] = '';
break;
case 'array':
$values[$key][$localeKey] = [];
break;
default:
$values[$key][$localeKey] = null;
}
}
}
return $values;
}
}
if (!PKP_STRICT_MODE) {
class_alias('\PKP\services\PKPSchemaService', '\PKPSchemaService');
foreach ([
'SCHEMA_ANNOUNCEMENT',
'SCHEMA_AUTHOR',
'SCHEMA_CONTEXT',
'SCHEMA_EMAIL_TEMPLATE',
'SCHEMA_GALLEY',
'SCHEMA_ISSUE',
'SCHEMA_PUBLICATION',
'SCHEMA_REVIEW_ASSIGNMENT',
'SCHEMA_REVIEW_ROUND',
'SCHEMA_SECTION',
'SCHEMA_SITE',
'SCHEMA_SUBMISSION',
'SCHEMA_SUBMISSION_FILE',
'SCHEMA_USER',
'SCHEMA_USER_GROUP',
] as $constantName) {
if (!defined($constantName)) {
define($constantName, constant('PKPSchemaService::' . $constantName));
}
}
}
|