1: <?php
2:
3: namespace Charcoal\Model\Service;
4:
5: use \RuntimeException;
6: use \InvalidArgumentException;
7:
8:
9: use \Psr\Log\LoggerAwareInterface;
10: use \Psr\Log\LoggerAwareTrait;
11:
12:
13: use \Psr\Cache\CacheItemPoolInterface;
14:
15:
16: use \Charcoal\Model\MetadataInterface;
17:
18: 19: 20: 21: 22: 23: 24: 25: 26: 27:
28: final class MetadataLoader implements LoggerAwareInterface
29: {
30: use LoggerAwareTrait;
31:
32: 33: 34: 35: 36:
37: private $cachePool;
38:
39: 40: 41: 42: 43:
44: private static $lineageCache = [];
45:
46: 47: 48: 49: 50:
51: private static $snakeCache = [];
52:
53: 54: 55: 56: 57:
58: private static $camelCache = [];
59:
60: 61: 62: 63: 64:
65: private $basePath = '';
66:
67: 68: 69: 70: 71:
72: private $paths = [];
73:
74: 75: 76: 77: 78: 79: 80: 81: 82: 83: 84: 85: 86: 87: 88:
89: public function __construct(array $data = null)
90: {
91: $this->setLogger($data['logger']);
92: $this->setCachePool($data['cache']);
93: $this->setBasePath($data['base_path']);
94: $this->setPaths($data['paths']);
95: }
96:
97: 98: 99: 100: 101: 102:
103: private function setCachePool(CacheItemPoolInterface $cache)
104: {
105: $this->cachePool = $cache;
106:
107: return $this;
108: }
109:
110: 111: 112: 113: 114: 115:
116: private function cachePool()
117: {
118: if (!isset($this->cachePool)) {
119: throw new RuntimeException(
120: sprintf('Cache Pool is not defined for "%s"', get_class($this))
121: );
122: }
123:
124: return $this->cachePool;
125: }
126:
127: 128: 129: 130: 131: 132: 133:
134: private function setBasePath($basePath)
135: {
136: if (!is_string($basePath)) {
137: throw new InvalidArgumentException(
138: 'Base path must be a string'
139: );
140: }
141:
142: $basePath = realpath($basePath);
143: $this->basePath = rtrim($basePath, '/\\').DIRECTORY_SEPARATOR;
144:
145: return $this;
146: }
147:
148: 149: 150: 151: 152:
153: private function basePath()
154: {
155: return $this->basePath;
156: }
157:
158: 159: 160: 161: 162: 163:
164: private function setPaths(array $paths)
165: {
166: $this->paths = [];
167: $this->addPaths($paths);
168:
169: return $this;
170: }
171:
172: 173: 174: 175: 176:
177: private function paths()
178: {
179: return $this->paths;
180: }
181:
182: 183: 184: 185: 186: 187:
188: private function addPaths(array $paths)
189: {
190: foreach ($paths as $path) {
191: $this->addPath($path);
192: }
193:
194: return $this;
195: }
196:
197: 198: 199: 200: 201: 202: 203:
204: private function addPath($path)
205: {
206: $path = $this->resolvePath($path);
207:
208: if ($this->validatePath($path)) {
209: $this->paths[] = $path;
210: }
211:
212: return $this;
213: }
214:
215: 216: 217: 218: 219: 220: 221:
222: private function resolvePath($path)
223: {
224: if (!is_string($path)) {
225: throw new InvalidArgumentException(
226: 'Path needs to be a string'
227: );
228: }
229:
230: $basePath = $this->basePath();
231: $path = ltrim($path, '/\\');
232:
233: if ($basePath && strpos($path, $basePath) === false) {
234: $path = $basePath.$path;
235: }
236:
237: return $path;
238: }
239:
240: 241: 242: 243: 244: 245:
246: private function validatePath($path)
247: {
248: return file_exists($path);
249: }
250:
251: 252: 253: 254: 255: 256: 257: 258: 259: 260: 261:
262: public function load($ident, MetadataInterface $metadata, array $interfaces = null)
263: {
264: if (!is_string($ident)) {
265: throw new InvalidArgumentException(
266: 'Metadata identifier must be a string'
267: );
268: }
269:
270: if (is_array($interfaces) && empty($interfaces)) {
271: $interfaces = null;
272: }
273:
274: $cacheKey = 'metadata/'.str_replace('/', '.', $ident);
275: $cacheItem = $this->cachePool()->getItem($cacheKey);
276:
277: if (!$cacheItem->isHit()) {
278: if ($interfaces === null) {
279: $data = $this->loadData($ident);
280: } else {
281: $data = $this->loadDataArray($interfaces);
282: }
283: $metadata->setData($data);
284:
285: $this->cachePool()->save($cacheItem->set($metadata));
286:
287: return $metadata;
288: }
289:
290: return $cacheItem->get();
291: }
292:
293: 294: 295: 296: 297: 298: 299:
300: public function loadData($ident)
301: {
302: if (!is_string($ident)) {
303: throw new InvalidArgumentException(
304: 'Metadata identifier must be a string'
305: );
306: }
307:
308: $lineage = $this->hierarchy($ident);
309: $catalog = [];
310: foreach ($lineage as $id) {
311: $data = $this->loadFileFromIdent($id);
312:
313: if (is_array($data)) {
314: $catalog = array_replace_recursive($catalog, $data);
315: }
316: }
317:
318: return $catalog;
319: }
320:
321: 322: 323: 324: 325: 326:
327: public function loadDataArray(array $idents)
328: {
329: $catalog = [];
330: foreach ($idents as $id) {
331: $data = $this->loadData($id);
332:
333: if (is_array($data)) {
334: $catalog = array_replace_recursive($catalog, $data);
335: }
336: }
337:
338: return $catalog;
339: }
340:
341: 342: 343: 344: 345: 346:
347: private function hierarchy($ident)
348: {
349: if (!is_string($ident)) {
350: return [];
351: }
352:
353: if (isset(static::$lineageCache[$ident])) {
354: return static::$lineageCache[$ident];
355: }
356:
357: $classname = $this->identToClassname($ident);
358:
359: return $this->classLineage($classname, $ident);
360: }
361:
362: 363: 364: 365: 366: 367: 368:
369: private function classLineage($classname, $ident = null)
370: {
371: if (!is_string($classname)) {
372: return [];
373: }
374:
375: if ($ident === null) {
376: $ident = $this->classnameToIdent($classname);
377: }
378:
379: if (isset(static::$lineageCache[$ident])) {
380: return static::$lineageCache[$ident];
381: }
382:
383: $classname = $this->identToClassname($ident);
384:
385: if (!class_exists($classname)) {
386: return [ $ident ];
387: }
388:
389: $classes = array_values(class_parents($classname));
390: $classes = array_reverse($classes);
391: $classes[] = $classname;
392:
393: $hierarchy = [];
394: foreach ($classes as $class) {
395: $implements = array_values(class_implements($class));
396: $implements = array_reverse($implements);
397: foreach ($implements as $interface) {
398: $hierarchy[$this->classnameToIdent($interface)] = $interface;
399: }
400: $hierarchy[$this->classnameToIdent($class)] = $class;
401: }
402:
403: $hierarchy = array_keys($hierarchy);
404:
405: static::$lineageCache[$ident] = $hierarchy;
406:
407: return $hierarchy;
408: }
409:
410: 411: 412: 413: 414: 415: 416: 417:
418: private function loadFileFromIdent($ident)
419: {
420: $filename = $this->filenameFromIdent($ident);
421:
422: return $this->loadFile($filename);
423: }
424:
425: 426: 427: 428: 429: 430: 431: 432:
433: private function loadFile($filename)
434: {
435: if (file_exists($filename)) {
436: return $this->loadJsonFile($filename);
437: }
438:
439: $paths = $this->paths();
440:
441: if (empty($paths)) {
442: return null;
443: }
444:
445: foreach ($paths as $basePath) {
446: $file = $basePath.DIRECTORY_SEPARATOR.$filename;
447: if (file_exists($file)) {
448: return $this->loadJsonFile($file);
449: }
450: }
451:
452: return null;
453: }
454:
455: 456: 457: 458: 459: 460: 461:
462: protected function loadJsonFile($filename)
463: {
464: $content = file_get_contents($filename);
465:
466: if ($content === null) {
467: return null;
468: }
469:
470: $data = json_decode($content, true);
471: $error = json_last_error();
472:
473: if ($error == JSON_ERROR_NONE) {
474: return $data;
475: }
476:
477: switch ($error) {
478: case JSON_ERROR_NONE:
479: break;
480: case JSON_ERROR_DEPTH:
481: $issue = 'Maximum stack depth exceeded';
482: break;
483: case JSON_ERROR_STATE_MISMATCH:
484: $issue = 'Underflow or the modes mismatch';
485: break;
486: case JSON_ERROR_CTRL_CHAR:
487: $issue = 'Unexpected control character found';
488: break;
489: case JSON_ERROR_SYNTAX:
490: $issue = 'Syntax error, malformed JSON';
491: break;
492: case JSON_ERROR_UTF8:
493: $issue = 'Malformed UTF-8 characters, possibly incorrectly encoded';
494: break;
495: default:
496: $issue = 'Unknown error';
497: break;
498: }
499:
500: throw new InvalidArgumentException(
501: sprintf('JSON %s could not be parsed: "%s"', $filename, $issue)
502: );
503: }
504:
505: 506: 507: 508: 509: 510:
511: private function filenameFromIdent($ident)
512: {
513: $filename = str_replace([ '\\' ], '.', $ident);
514: $filename .= '.json';
515:
516: return $filename;
517: }
518:
519: 520: 521: 522: 523: 524:
525: private function identToClassname($ident)
526: {
527: $key = $ident;
528:
529: if (isset(static::$camelCache[$key])) {
530: return static::$camelCache[$key];
531: }
532:
533:
534: $parts = explode('-', $ident);
535: array_walk(
536: $parts,
537: function(&$i) {
538: $i = ucfirst($i);
539: }
540: );
541: $ident = implode('', $parts);
542:
543:
544: $classname = str_replace('/', '\\', $ident);
545: $parts = explode('\\', $classname);
546:
547: array_walk(
548: $parts,
549: function(&$i) {
550: $i = ucfirst($i);
551: }
552: );
553:
554: $classname = trim(implode('\\', $parts), '\\');
555:
556: static::$camelCache[$key] = $classname;
557: static::$snakeCache[$classname] = $key;
558:
559: return $classname;
560: }
561:
562: 563: 564: 565: 566: 567:
568: private function classnameToIdent($classname)
569: {
570: $key = trim($classname, '\\');
571:
572: if (isset(static::$snakeCache[$key])) {
573: return static::$snakeCache[$key];
574: }
575:
576: $ident = strtolower(preg_replace('/([a-z])([A-Z])/', '$1-$2', $classname));
577: $ident = str_replace('\\', '/', strtolower($ident));
578: $ident = ltrim($ident, '/');
579:
580: static::$snakeCache[$key] = $ident;
581: static::$camelCache[$ident] = $key;
582:
583: return $ident;
584: }
585: }
586: