637 lines
22 KiB
PHP
637 lines
22 KiB
PHP
<?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));
|
|
}
|
|
}
|
|
}
|