Completed
Push — feature/other-validation ( 1213d0...616c5e )
by Narcotic
64:58
created

SchemaUtils   C

Complexity

Total Complexity 55

Size/Duplication

Total Lines 475
Duplicated Lines 3.37 %

Coupling/Cohesion

Components 1
Dependencies 13

Test Coverage

Coverage 5.63%

Importance

Changes 14
Bugs 2 Features 4
Metric Value
wmc 55
c 14
b 2
f 4
lcom 1
cbo 13
dl 16
loc 475
ccs 12
cts 213
cp 0.0563
rs 6.8

7 Methods

Rating   Name   Duplication   Size   Complexity  
B __construct() 0 25 1
A getCollectionSchema() 0 10 1
F getModelSchema() 16 252 43
A makeTranslatable() 0 18 1
A makeArrayTranslatable() 0 6 1
A getSchemaRouteName() 0 13 3
B getCollectionItemType() 0 15 5

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like SchemaUtils often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use SchemaUtils, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * Utils for generating schemas.
4
 */
5
6
namespace Graviton\SchemaBundle;
7
8
use Doctrine\Common\Cache\CacheProvider;
9
use Graviton\I18nBundle\Document\TranslatableDocumentInterface;
10
use Graviton\I18nBundle\Document\Language;
11
use Graviton\I18nBundle\Repository\LanguageRepository;
12
use Graviton\RestBundle\Model\DocumentModel;
13
use Graviton\SchemaBundle\Constraint\ConstraintBuilder;
14
use Graviton\SchemaBundle\Document\Schema;
15
use Graviton\SchemaBundle\Document\SchemaAdditionalProperties;
16
use Graviton\SchemaBundle\Document\SchemaType;
17
use Graviton\SchemaBundle\Service\RepositoryFactory;
18
use Metadata\MetadataFactoryInterface as SerializerMetadataFactoryInterface;
19
use Symfony\Component\Routing\RouterInterface;
20
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
21
22
/**
23
 * Utils for generating schemas.
24
 *
25
 * @author   List of contributors <https://github.com/libgraviton/graviton/graphs/contributors>
26
 * @license  http://opensource.org/licenses/gpl-license.php GNU Public License
27
 * @link     http://swisscom.ch
28
 */
29
class SchemaUtils
30
{
31
32
    /**
33
     * language repository
34
     *
35
     * @var LanguageRepository repository
36
     */
37
    private $languageRepository;
38
39
    /**
40
     * router
41
     *
42
     * @var RouterInterface router
43
     */
44
    private $router;
45
46
    /**
47
     * mapping service names => route names
48
     *
49
     * @var array service mapping
50
     */
51
    private $extrefServiceMapping;
52
53
    /**
54
     * event map
55
     *
56
     * @var array event map
57
     */
58
    private $eventMap;
59
60
    /**
61
     * @var array [document class => [field name -> exposed name]]
62
     */
63
    private $documentFieldNames;
64
65
    /**
66
     * @var string
67
     */
68
    private $defaultLocale;
69
70
    /**
71
     * @var RepositoryFactory
72
     */
73
    private $repositoryFactory;
74
75
    /**
76
     * @var SerializerMetadataFactoryInterface
77
     */
78
    private $serializerMetadataFactory;
79
80
    /**
81
     * @var CacheProvider
82
     */
83
    private $cache;
84
85
    /**
86
     * @var string
87
     */
88
    private $cacheInvalidationMapKey;
89
90
    /**
91
     * @var ConstraintBuilder
92
     */
93
    private $constraintBuilder;
94
95
    /**
96
     * Constructor
97
     *
98
     * @param RepositoryFactory                  $repositoryFactory         Create repos from model class names
99
     * @param SerializerMetadataFactoryInterface $serializerMetadataFactory Serializer metadata factory
100
     * @param LanguageRepository                 $languageRepository        repository
101
     * @param RouterInterface                    $router                    router
102
     * @param array                              $extrefServiceMapping      Extref service mapping
103 2
     * @param array                              $eventMap                  eventmap
104
     * @param array                              $documentFieldNames        Document field names
105
     * @param string                             $defaultLocale             Default Language
106
     * @param ConstraintBuilder                  $constraintBuilder         Constraint builder
107
     * @param CacheProvider                      $cache                     Doctrine cache provider
108
     * @param string                             $cacheInvalidationMapKey   Cache invalidation map cache key
109
     */
110
    public function __construct(
111
        RepositoryFactory $repositoryFactory,
112
        SerializerMetadataFactoryInterface $serializerMetadataFactory,
113
        LanguageRepository $languageRepository,
114
        RouterInterface $router,
115 2
        array $extrefServiceMapping,
116 2
        array $eventMap,
117 2
        array $documentFieldNames,
118 2
        $defaultLocale,
119 2
        ConstraintBuilder $constraintBuilder,
120 2
        CacheProvider $cache,
121 2
        $cacheInvalidationMapKey
122 2
    ) {
123 2
        $this->repositoryFactory = $repositoryFactory;
124 2
        $this->serializerMetadataFactory = $serializerMetadataFactory;
125 2
        $this->languageRepository = $languageRepository;
126
        $this->router = $router;
127
        $this->extrefServiceMapping = $extrefServiceMapping;
128
        $this->eventMap = $eventMap;
129
        $this->documentFieldNames = $documentFieldNames;
130
        $this->defaultLocale = $defaultLocale;
131
        $this->constraintBuilder = $constraintBuilder;
132
        $this->cache = $cache;
133
        $this->cacheInvalidationMapKey = $cacheInvalidationMapKey;
134
    }
135
136
    /**
137
     * get schema for an array of models
138
     *
139
     * @param string        $modelName name of model
140
     * @param DocumentModel $model     model
141
     *
142
     * @return Schema
143
     */
144
    public function getCollectionSchema($modelName, DocumentModel $model)
145
    {
146
        $collectionSchema = new Schema;
147
        $collectionSchema->setTitle(sprintf('Array of %s objects', $modelName));
148
        $collectionSchema->setType('array');
149
150
        $collectionSchema->setItems($this->getModelSchema($modelName, $model));
151
152
        return $collectionSchema;
153
    }
154
155
    /**
156
     * return the schema for a given route
157
     *
158
     * @param string        $modelName name of mode to generate schema for
159
     * @param DocumentModel $model     model to generate schema for
160
     * @param boolean       $online    if we are online and have access to mongodb during this build
161
     * @param boolean       $internal  if true, we generate the schema for internal validation use
162
     *
163
     * @return Schema
164
     */
165
    public function getModelSchema($modelName, DocumentModel $model, $online = true, $internal = false)
166
    {
167
        $cacheKey = 'schema'.$model->getEntityClass().'.'.(string) $online.'.'.(string) $internal;
168
169
        if ($this->cache->contains($cacheKey)) {
170
            return $this->cache->fetch($cacheKey);
171
        }
172
173
        $invalidateCacheMap = [];
174
        if ($this->cache->contains($this->cacheInvalidationMapKey)) {
175
            $invalidateCacheMap = $this->cache->fetch($this->cacheInvalidationMapKey);
176
        }
177
178
        // build up schema data
179
        $schema = new Schema;
180
181
        if (!empty($model->getTitle())) {
182
            $schema->setTitle($model->getTitle());
183
        } else {
184
            if (!is_null($modelName)) {
185
                $schema->setTitle(ucfirst($modelName));
186
            } else {
187
                $reflection = new \ReflectionClass($model);
188
                $schema->setTitle(ucfirst($reflection->getShortName()));
189
            }
190
        }
191
192
        $schema->setDescription($model->getDescription());
193
        $schema->setDocumentClass($model->getDocumentClass());
0 ignored issues
show
Security Bug introduced by
It seems like $model->getDocumentClass() targeting Graviton\SchemaBundle\Mo...del::getDocumentClass() can also be of type false; however, Graviton\SchemaBundle\Do...ema::setDocumentClass() does only seem to accept string, did you maybe forget to handle an error condition?
Loading history...
194
        $schema->setType('object');
195
196
        // grab schema info from model
197
        $repo = $model->getRepository();
198
        $meta = $repo->getClassMetadata();
199
200
        // Init sub searchable fields
201
        $subSearchableFields = array();
202
203
        // look for translatables in document class
204
        $documentReflection = new \ReflectionClass($repo->getClassName());
205
        if ($documentReflection->implementsInterface('Graviton\I18nBundle\Document\TranslatableDocumentInterface')) {
206
            /** @var TranslatableDocumentInterface $documentInstance */
207
            $documentInstance = $documentReflection->newInstanceWithoutConstructor();
208
            $translatableFields = array_merge(
209
                $documentInstance->getTranslatableFields(),
210
                $documentInstance->getPreTranslatedFields()
211
            );
212
        } else {
213
            $translatableFields = [];
214
        }
215
216
        if (!empty($translatableFields)) {
217
            $invalidateCacheMap[$this->languageRepository->getClassName()][] = $cacheKey;
218
        }
219
220
        // exposed fields
221
        $documentFieldNames = isset($this->documentFieldNames[$repo->getClassName()]) ?
222
            $this->documentFieldNames[$repo->getClassName()] :
223
            [];
224
225
        $languages = [];
226
        if ($online) {
227
            $languages = array_map(
228
                function (Language $language) {
229
                    return $language->getId();
230
                },
231
                $this->languageRepository->findAll()
232
            );
233
        }
234
        if (empty($languages)) {
235
            $languages = [
236
                $this->defaultLocale
237
            ];
238
        }
239
240
        // exposed events..
241
        $classShortName = $documentReflection->getShortName();
242
        if (isset($this->eventMap[$classShortName])) {
243
            $schema->setEventNames(array_unique($this->eventMap[$classShortName]['events']));
244
        }
245
246
        $requiredFields = [];
247
        $modelRequiredFields = $model->getRequiredFields();
248
        if (is_array($modelRequiredFields)) {
249
            foreach ($modelRequiredFields as $field) {
250
                // don't describe hidden fields
251
                if (!isset($documentFieldNames[$field])) {
252
                    continue;
253
                }
254
255
                $requiredFields[] = $documentFieldNames[$field];
256
            }
257
        }
258
259
        foreach ($meta->getFieldNames() as $field) {
260
            // don't describe hidden fields
261
            if (!isset($documentFieldNames[$field])) {
262
                continue;
263
            }
264
            // hide realId field (I was aiming at a cleaner solution than the macig realId string initially)
265
            if ($meta->getTypeOfField($field) == 'id' && $field == 'realId') {
266
                continue;
267
            }
268
269
            $property = new Schema();
270
            $property->setTitle($model->getTitleOfField($field));
271
            $property->setDescription($model->getDescriptionOfField($field));
272
273
            $property->setType($meta->getTypeOfField($field));
274
            $property->setReadOnly($model->getReadOnlyOfField($field));
275
276
            if ($meta->getTypeOfField($field) === 'many') {
277
                $propertyModel = $model->manyPropertyModelForTarget($meta->getAssociationTargetClass($field));
278
279
                if ($model->hasDynamicKey($field)) {
280
                    $property->setType('object');
281
282
                    if ($online) {
283
                        // we generate a complete list of possible keys when we have access to mongodb
284
                        // this makes everything work with most json-schema v3 implementations (ie. schemaform.io)
285
                        $dynamicKeySpec = $model->getDynamicKeySpec($field);
286
287
                        $documentId = $dynamicKeySpec->{'document-id'};
288
                        $dynamicRepository = $this->repositoryFactory->get($documentId);
289
290
                        // put this in invalidate map so when know we have to invalidate when this document is used
291
                        $invalidateCacheMap[$dynamicRepository->getDocumentName()][] = $cacheKey;
0 ignored issues
show
Bug introduced by
The method getDocumentName() does not seem to exist on object<Doctrine\Common\Persistence\ObjectManager>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
292
293
                        $repositoryMethod = $dynamicKeySpec->{'repository-method'};
294
                        $records = $dynamicRepository->$repositoryMethod();
295
296
                        $dynamicProperties = array_map(
297
                            function ($record) {
298
                                return $record->getId();
299
                            },
300
                            $records
301
                        );
302
                        foreach ($dynamicProperties as $propertyName) {
303
                            $property->addProperty(
304
                                $propertyName,
305
                                $this->getModelSchema($field, $propertyModel, $online)
306
                            );
307
                        }
308
                    } else {
309
                        // swagger case
310
                        $property->setAdditionalProperties(
311
                            new SchemaAdditionalProperties($this->getModelSchema($field, $propertyModel, $online))
312
                        );
313
                    }
314
                } else {
315
                    $property->setItems($this->getModelSchema($field, $propertyModel, $online));
316
                    $property->setType('array');
317
                }
318
            } elseif ($meta->getTypeOfField($field) === 'one') {
319
                $propertyModel = $model->manyPropertyModelForTarget($meta->getAssociationTargetClass($field));
320
                $property = $this->getModelSchema($field, $propertyModel, $online);
321
322
                if ($property->getSearchable()) {
323
                    foreach ($property->getSearchable() as $searchableSubField) {
324
                        $subSearchableFields[] = $field . '.' . $searchableSubField;
325
                    }
326
                }
327
            } elseif (in_array($field, $translatableFields, true)) {
328
                $property = $this->makeTranslatable($property, $languages);
329
            } elseif (in_array($field.'[]', $translatableFields, true)) {
330
                $property = $this->makeArrayTranslatable($property, $languages);
331
            } elseif ($meta->getTypeOfField($field) === 'extref') {
332
                $urls = array();
333
                $refCollections = $model->getRefCollectionOfField($field);
334
                foreach ($refCollections as $collection) {
335
                    if (isset($this->extrefServiceMapping[$collection])) {
336
                        $urls[] = $this->router->generate(
337
                            $this->extrefServiceMapping[$collection].'.all',
338
                            [],
339
                            UrlGeneratorInterface::ABSOLUTE_URL
340
                        );
341
                    } elseif ($collection === '*') {
342
                        $urls[] = '*';
343
                    }
344
                }
345
                $property->setRefCollection($urls);
346 View Code Duplication
            } elseif ($meta->getTypeOfField($field) === 'collection') {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
347
                $itemSchema = new Schema();
348
                $property->setType('array');
349
                $itemSchema->setType($this->getCollectionItemType($meta->name, $field));
350
351
                $property->setItems($itemSchema);
352
                $property->setFormat(null);
353
            } elseif ($meta->getTypeOfField($field) === 'datearray') {
354
                $itemSchema = new Schema();
355
                $property->setType('array');
356
                $itemSchema->setType('string');
357
                $itemSchema->setFormat('date-time');
358
359
                $property->setItems($itemSchema);
360
                $property->setFormat(null);
361 View Code Duplication
            } elseif ($meta->getTypeOfField($field) === 'hasharray') {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
362
                $itemSchema = new Schema();
363
                $itemSchema->setType('object');
364
365
                $property->setType('array');
366
                $property->setItems($itemSchema);
367
                $property->setFormat(null);
368
            } elseif (in_array($meta->getTypeOfField($field), $property->getMinLengthTypes())) {
369
                // make sure a required field cannot be blank
370
                if (in_array($documentFieldNames[$field], $requiredFields)) {
371
                    $property->setMinLength(1);
372
                } else {
373
                    // in the other case, make sure also null can be sent..
374
                    $currentType = $property->getType();
375
                    if ($currentType instanceof SchemaType) {
376
                        $property->setType(array_merge($currentType->getTypes(), ['null']));
377
                    } else {
378
                        $property->setType('null');
379
                    }
380
                }
381
            }
382
383
            $property = $this->constraintBuilder->addConstraints($field, $property, $model);
384
385
            $schema->addProperty($documentFieldNames[$field], $property);
386
        }
387
388
        if ($meta->isEmbeddedDocument && !in_array('id', $model->getRequiredFields())) {
389
            $schema->removeProperty('id');
390
        }
391
392
        /**
393
         * if we generate schema for internal use; don't have id in required array as
394
         * it's 'requiredness' depends on the method used (POST/PUT/PATCH) and is checked in checks
395
         * before validation.
396
         */
397
        $idPosition = array_search('id', $requiredFields);
398
        if ($internal === true && $idPosition !== false) {
399
            unset($requiredFields[$idPosition]);
400
        }
401
402
        $schema->setRequired($requiredFields);
403
404
        // set additionalProperties to false (as this is our default policy) if not already set
405
        if (is_null($schema->getAdditionalProperties()) && $online) {
406
            $schema->setAdditionalProperties(new SchemaAdditionalProperties(false));
407
        }
408
409
        $searchableFields = array_merge($subSearchableFields, $model->getSearchableFields());
410
        $schema->setSearchable($searchableFields);
411
412
        $this->cache->save($cacheKey, $schema);
413
        $this->cache->save($this->cacheInvalidationMapKey, $invalidateCacheMap);
414
415
        return $schema;
416
    }
417
418
    /**
419
     * turn a property into a translatable property
420
     *
421
     * @param Schema   $property  simple string property
422
     * @param string[] $languages available languages
423
     *
424
     * @return Schema
425
     */
426
    public function makeTranslatable(Schema $property, $languages)
427
    {
428
        $property->setType('object');
429
        $property->setTranslatable(true);
430
431
        array_walk(
432
            $languages,
433
            function ($language) use ($property) {
434
                $schema = new Schema;
435
                $schema->setType('string');
436
                $schema->setTitle('Translated String');
437
                $schema->setDescription('String in ' . $language . ' locale.');
438
                $property->addProperty($language, $schema);
439
            }
440
        );
441
        $property->setRequired(['en']);
442
        return $property;
443
    }
444
445
    /**
446
     * turn a array property into a translatable property
447
     *
448
     * @param Schema   $property  simple string property
449
     * @param string[] $languages available languages
450
     *
451
     * @return Schema
452
     */
453
    public function makeArrayTranslatable(Schema $property, $languages)
454
    {
455
        $property->setType('array');
456
        $property->setItems($this->makeTranslatable(new Schema(), $languages));
457
        return $property;
458
    }
459
460
    /**
461
     * get canonical route to a schema based on a route
462
     *
463
     * @param string $routeName route name
464
     *
465
     * @return string schema route name
466
     */
467
    public static function getSchemaRouteName($routeName)
468
    {
469
        $routeParts = explode('.', $routeName);
470
471
        $routeType = array_pop($routeParts);
472
        // check if we need to create an item or collection schema
473
        $realRouteType = 'canonicalSchema';
474
        if ($routeType != 'options' && $routeType != 'all') {
475
            $realRouteType = 'canonicalIdSchema';
476
        }
477
478
        return implode('.', array_merge($routeParts, array($realRouteType)));
479
    }
480
481
    /**
482
     * Get item type of collection field
483
     *
484
     * @param string $className Class name
485
     * @param string $fieldName Field name
486
     * @return string|null
487
     */
488
    private function getCollectionItemType($className, $fieldName)
489
    {
490
        $serializerMetadata = $this->serializerMetadataFactory->getMetadataForClass($className);
491
        if ($serializerMetadata === null) {
492
            return null;
493
        }
494
        if (!isset($serializerMetadata->propertyMetadata[$fieldName])) {
495
            return null;
496
        }
497
498
        $type = $serializerMetadata->propertyMetadata[$fieldName]->type;
499
        return isset($type['name'], $type['params'][0]['name']) && $type['name'] === 'array' ?
500
            $type['params'][0]['name'] :
501
            null;
502
    }
503
}
504