src/SchemaParser.php
<?php
namespace Gdbots\Pbjc;
use Gdbots\Pbjc\Enum\TypeName;
use Gdbots\Pbjc\Exception\MissingSchema;
use Gdbots\Pbjc\Util\LanguageBag;
use Gdbots\Pbjc\Util\XmlUtils;
/**
* The SchemaParser is a tool to create/update schemas class descriptors.
*/
class SchemaParser
{
/** @var array */
protected $files = [];
/**
* @param string $file
*
* @return array
*
* @throws \RuntimeException
*/
private function getXmlData($file)
{
/** @var \DOMDocument $xmlDomDocument */
if (!$xmlDomDocument = XmlUtils::loadFile($file, __DIR__ . '/../xsd/schema.xsd')) {
throw new \RuntimeException(sprintf(
'Invalid schema xml file "%s".',
$file
));
}
/** @var array $xmlData */
if (!$xmlData = XmlUtils::convertDomElementToArray($xmlDomDocument->firstChild)) {
throw new \RuntimeException('Invalid schema DOM object.');
}
$schemaId = SchemaId::fromString($xmlData['schema']['id']);
$filePath = substr($file, 0, -strlen(basename($file)) - 1);
$schemaPath = str_replace([':', '//'], ['/', '/'], $schemaId->getCurie());
// invalid schema file location
if (substr($filePath, -strlen($schemaPath)) !== $schemaPath) {
throw new \RuntimeException(sprintf(
'Invalid schema xml directory "%s". Expected sub-directory "%s".',
$filePath,
$schemaPath
));
}
// validate version to file
if (basename($file) != 'latest.xml'
&& basename($file) != sprintf('%s.xml', $schemaId->getVersion()->toString())
) {
throw new \RuntimeException(sprintf(
'Invalid schema xml file "%s" name. Expected name "%s.xml".',
$file,
$schemaId->getVersion()->toString()
));
}
return $xmlData;
}
/**
* Reads and validate XML file.
*
* @param string $file
*
* @return SchemaDescriptor|null
*
* @throws \RuntimeException
* @throws MissingSchema
*/
public function fromFile($file)
{
if (!array_key_exists($file, $this->files)) {
$xmlData = $this->getXmlData($file);
$schemaId = SchemaId::fromString($xmlData['schema']['id']);
$filePath = substr($file, 0, -strlen(basename($file)) - 1);
// check "latest.xml"
$latestPath = sprintf('%s/latest.xml', $filePath);
if (!file_exists($latestPath)) {
file_put_contents($latestPath, file_get_contents($file));
$this->files[$latestPath] = $xmlData['schema'];
}
// override schema with "latest"
if (basename($file) != 'latest.xml' && file_exists($latestPath)) {
$lastestXmlData = $this->getXmlData($latestPath);
$latestSchemaId = SchemaId::fromString($lastestXmlData['schema']['id']);
if ($schemaId->getVersion()->toString() === $latestSchemaId->getVersion()->toString()
&& $xmlData != $lastestXmlData
) {
file_put_contents($file, file_get_contents($latestPath));
$xmlData = $lastestXmlData;
}
}
// update "latest" with schema
if (isset($this->files[$latestPath])) {
$version = SchemaId::fromString($this->files[$latestPath]['id'])->getVersion()->toString();
if (version_compare($schemaId->getVersion()->toString(), $version) === 1) {
file_put_contents($latestPath, file_get_contents($file));
$this->files[$latestPath] = $xmlData['schema'];
}
}
// override or create "latest" version file
$versionPath = sprintf('%s/%s.xml', $filePath, $schemaId->getVersion()->toString());
if (basename($file) == 'latest.xml') {
file_put_contents($versionPath, file_get_contents($file));
}
$this->files[$file] = $xmlData['schema'];
}
return $this->parse($this->files[$file]);
}
/**
* Builds a Schema instance from a given set of data.
*
* @param array $data
*
* @return SchemaDescriptor
*
* @throws \InvalidArgumentException
* @throws MissingSchema
*/
private function parse(array $data)
{
$schemaId = SchemaId::fromString($data['id']);
$parameters = [
'deprecated' => isset($data['deprecated']) && $data['deprecated'],
];
if (isset($data['extends'])) {
if ($data['extends'] == $schemaId->getCurieWithMajorRev()) {
throw new \InvalidArgumentException(sprintf(
'Cannot extends yourself "%s".',
$schemaId->toString()
));
}
if (!$extendsSchema = SchemaStore::getSchemaById($data['extends'], true)) {
throw new MissingSchema($data['extends']);
}
// recursively check that chain not pointing back to schema
$check = $extendsSchema->getExtends();
while ($check) {
if ($check->getId()->getCurieWithMajorRev() == $schemaId->getCurieWithMajorRev()) {
throw new \InvalidArgumentException(sprintf(
'Invalid extends chain. Schema "%s" pointing back to you "%s".',
$check->getId()->toString(),
$schemaId->toString()
));
}
$check = $check->getExtends();
}
$parameters['extends'] = $extendsSchema;
}
if (isset($data['mixin']) && $data['mixin']) {
$parameters['isMixin'] = true;
}
// default language options
$parameters['languages'] = $this->getLanguageOptions($data);
if (isset($data['fields']['field'])) {
$fieldsData = $this->fixArray($data['fields']['field'], 'name');
if (count($fieldsData)) {
$parameters['fields'] = [];
}
foreach ($fieldsData as $field) {
if ($field = $this->getFieldDescriptor($schemaId, $field)) {
$parameters['fields'][] = $field;
}
}
}
if (isset($data['mixins']['curie-major'])) {
$mixinsData = $this->fixArray($data['mixins']['curie-major']);
if (count($mixinsData)) {
$parameters['mixins'] = [];
}
foreach ($mixinsData as $curieWithMajorRev) {
if ($mixin = $this->getMixin($schemaId, $curieWithMajorRev)) {
$parameters['mixins'][] = $mixin;
}
}
}
return new SchemaDescriptor($schemaId, $parameters);
}
/**
* @param array|string $data
* @param string $key
*
* @return array
*/
private function fixArray($data, $key = null)
{
if (!is_array($data) || ($key && isset($data[$key]))) {
$data = [$data];
}
// SORT_REGULAR = compare items normally (don't change types)
return array_unique($data, SORT_REGULAR);
}
/**
* @param array $data
*
* @return array
*/
private function getLanguageOptions(array $data)
{
$options = new LanguageBag();
foreach ($data as $key => $value) {
if (substr($key, -8) == '-options') {
$language = substr($key, 0, -8); // remove "-options"
if (is_array($value)) {
$value = new LanguageBag($value);
}
$options->set($language, $value);
}
}
return $options;
}
/**
* @param SchemaId $schemaId
* @param array $field
*
* @return FieldDescriptor|null
*/
private function getFieldDescriptor(SchemaId $schemaId, array $field)
{
// force default type to be "string"
if (!isset($field['type'])) {
$field['type'] = 'string';
}
if (!isset($field['options'])) {
$field['options'] = [];
}
if (isset($field['any-of']) &&
in_array($field['type'], [
TypeName::GEO_POINT(),
TypeName::IDENTIFIER(),
TypeName::MESSAGE_REF(),
])
) {
unset($field['any-of']);
}
if (isset($field['any-of']['curie'])) {
$field['any-of'] = $this->getAnyOf(
$schemaId,
$this->fixArray($field['any-of']['curie'])
);
}
if (isset($field['any-of']) && count($field['any-of']) === 0) {
unset($field['any-of']);
}
if (isset($field['enum'])) {
/** @var $enum EnumDescriptor */
if (!$enum = $this->getEnumById($field['enum']['id'])) {
throw new \RuntimeException(sprintf(
'No Enum with id ["%s"] exist.',
$field['enum']['id']
));
}
switch ($field['type']) {
case 'int-enum':
case 'string-enum':
if (substr($field['type'], 0, -5) != $enum->getType()) {
throw new \RuntimeException(sprintf(
'Invalid Enum ["%s"] type. A ["%s-enum"] is required.',
$field['enum']['name'],
$enum->getType()
));
}
break;
default:
throw new \RuntimeException(sprintf(
'Invalid Enum type.'
));
}
$field['enum'] = $enum;
}
return new FieldDescriptor($field['name'], $field);
}
/**
* @param SchemaId $schemaId
* @param array $curies
*
* @return array
*
* @throws \InvalidArgumentException
* @throws MissingSchema
*/
private function getAnyOf($schemaId, $curies)
{
if (in_array($schemaId->getCurie(), $curies)) {
throw new \InvalidArgumentException(sprintf(
'Cannot add yourself "%s" as to anyof.',
$schemaId->toString()
));
}
$schemas = [];
foreach ($curies as $curie) {
if (!$schema = SchemaStore::getSchemaById($curie, true)) {
throw new MissingSchema($curie);
}
$schemas[] = $schema;
}
return $schemas;
}
/**
* @param string $id
*
* @return EnumDescriptor
*
* @throws \InvalidArgumentException
*/
private function getEnumById($id)
{
if (!$enum = SchemaStore::getEnumById($id, true)) {
throw new \InvalidArgumentException(sprintf(
'Cannot find an enum with id "%s"',
$id
));
}
return $enum;
}
/**
* @param SchemaId $schemaId
* @param string $curieWithMajorRev
*
* @return SchemaDescriptor|null
*
* @throws \InvalidArgumentException
* @throws MissingSchema
*/
private function getMixin(SchemaId $schemaId, $curieWithMajorRev)
{
if ($curieWithMajorRev == $schemaId->getCurieWithMajorRev()) {
throw new \InvalidArgumentException(sprintf(
'Cannot add yourself "%s" as to mixins.',
$schemaId->toString()
));
}
if (!$schema = SchemaStore::getSchemaById($curieWithMajorRev, true)) {
throw new MissingSchema($curieWithMajorRev);
}
return $schema;
}
}