Completed
Push — develop ( b85182...537c08 )
by Narcotic
02:54
created

ImportCommand   B

Complexity

Total Complexity 28

Size/Duplication

Total Lines 406
Duplicated Lines 0 %

Coupling/Cohesion

Components 2
Dependencies 16

Test Coverage

Coverage 80.3%

Importance

Changes 0
Metric Value
wmc 28
c 0
b 0
f 0
lcom 2
cbo 16
dl 0
loc 406
ccs 163
cts 203
cp 0.803
rs 8.4614

7 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 19 1
A configure() 0 60 1
B doImport() 0 32 3
B importPaths() 0 31 3
F importResource() 0 98 12
B parseContent() 0 33 5
A validateUploadFile() 0 17 3
1
<?php
2
/**
3
 * import json data into graviton
4
 *
5
 * Supports importing json data from either a single file or a complete folder of files.
6
 *
7
 * The data needs to contain frontmatter to hint where the bits and pieces should go.
8
 */
9
10
namespace Graviton\ImportExport\Command;
11
12
use Graviton\ImportExport\Exception\MissingTargetException;
13
use Graviton\ImportExport\Exception\JsonParseException;
14
use Graviton\ImportExport\Exception\ParseException;
15
use Graviton\ImportExport\Exception\UnknownFileTypeException;
16
use Graviton\ImportExport\Service\HttpClient;
17
use Monolog\Logger;
18
use Symfony\Component\Console\Input\InputArgument;
19
use Symfony\Component\Console\Input\InputOption;
20
use Symfony\Component\Console\Input\InputInterface;
21
use Symfony\Component\Console\Output\OutputInterface;
22
use Symfony\Component\Finder\Finder;
23
use Symfony\Component\Yaml\Parser;
24
use Symfony\Component\VarDumper\Cloner\VarCloner;
25
use Symfony\Component\VarDumper\Dumper\CliDumper as Dumper;
26
use GuzzleHttp\Promise;
27
use Symfony\Component\Finder\SplFileInfo;
28
use Symfony\Component\Yaml\Yaml;
29
use Webuni\FrontMatter\FrontMatter;
30
use Webuni\FrontMatter\Document;
31
32
/**
33
 * @author   List of contributors <https://github.com/libgraviton/import-export/graphs/contributors>
34
 * @license  http://opensource.org/licenses/gpl-license.php GNU Public License
35
 * @link     http://swisscom.ch
36
 */
37
class ImportCommand extends ImportCommandAbstract
38
{
39
40
    /**
41
     * @var HttpClient
42
     */
43
    private $client;
44
45
    /**
46
     * @var FrontMatter
47
     */
48
    private $frontMatter;
49
50
    /**
51
     * @var Parser
52
     */
53
    private $parser;
54
55
    /**
56
     * @var VarCloner
57
     */
58
    private $cloner;
59
60
    /**
61
     * @var Dumper
62
     */
63
    private $dumper;
64
65
    /**
66
     * Count of errors
67
     * @var array
68
     */
69
    private $errors = [];
70
71
    /**
72
     * Header basic auth
73
     * @var string
74
     */
75
    private $headerBasicAuth;
76
77
    /**
78
     * Header for custom variables
79
     * @var array
80
     */
81
    private $customHeaders;
82
83
    /**
84
     * @param Logger      $logger      logger
85
     * @param HttpClient  $client      Grv HttpClient guzzle http client
86
     * @param Finder      $finder      symfony/finder instance
87
     * @param FrontMatter $frontMatter frontmatter parser
88
     * @param Parser      $parser      yaml/json parser
89
     * @param VarCloner   $cloner      var cloner for dumping reponses
90
     * @param Dumper      $dumper      dumper for outputing responses
91
     */
92 5
    public function __construct(
93
        Logger $logger,
94
        HttpClient $client,
95
        Finder $finder,
96
        FrontMatter $frontMatter,
97
        Parser $parser,
98
        VarCloner $cloner,
99
        Dumper $dumper
100
    ) {
101 5
        parent::__construct(
102 5
            $logger,
103
            $finder
104 5
        );
105 5
        $this->client = $client;
106 5
        $this->frontMatter = $frontMatter;
107 5
        $this->parser = $parser;
108 5
        $this->cloner = $cloner;
109 5
        $this->dumper = $dumper;
110 5
    }
111
112
    /**
113
     * Configures the current command.
114
     *
115
     * @return void
116
     */
117 5
    protected function configure()
118
    {
119 5
        $this
120 5
            ->setName('graviton:import')
121 5
            ->setDescription('Import files from a folder or file.')
122 5
            ->addOption(
123 5
                'rewrite-host',
124 5
                'r',
125 5
                InputOption::VALUE_OPTIONAL,
126 5
                'Replace the value of this option with the <host> value before importing.',
127
                'http://localhost'
128 5
            )
129 5
            ->addOption(
130 5
                'rewrite-to',
131 5
                't',
132 5
                InputOption::VALUE_OPTIONAL,
133 5
                'String to use as the replacement value for the [REWRITE-HOST] string.',
134
                '<host>'
135 5
            )
136 5
            ->addOption(
137 5
                'sync-requests',
138 5
                's',
139 5
                InputOption::VALUE_NONE,
140
                'Send requests synchronously'
141 5
            )
142 5
            ->addOption(
143 5
                'no-overwrite',
144 5
                'o',
145 5
                InputOption::VALUE_NONE,
146
                'If set, we will check for record existence and not overwrite existing ones.'
147 5
            )
148 5
            ->addOption(
149 5
                'headers-basic-auth',
150 5
                'a',
151 5
                InputOption::VALUE_OPTIONAL,
152
                'Header user:password for Basic auth'
153 5
            )
154 5
            ->addOption(
155 5
                'custom-headers',
156 5
                'c',
157 5
                InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY,
158
                'Custom Header variable(s), -c{key:value} and multiple is optional.'
159 5
            )
160 5
            ->addOption(
161 5
                'input-file',
162 5
                'i',
163 5
                InputOption::VALUE_REQUIRED,
164
                'If provided, the list of files to load will be loaded from this file, one file per line.'
165 5
            )
166 5
            ->addArgument(
167 5
                'host',
168 5
                InputArgument::REQUIRED,
169
                'Protocol and host to load data into (ie. https://graviton.nova.scapp.io)'
170 5
            )
171 5
            ->addArgument(
172 5
                'file',
173 5
                InputArgument::IS_ARRAY,
174
                'Directories or files to load'
175 5
            );
176 5
    }
177
178
    /**
179
     * Executes the current command.
180
     *
181
     * @param Finder          $finder Finder
182
     * @param InputInterface  $input  User input on console
183
     * @param OutputInterface $output Output of the command
184
     *
185
     * @return integer
186
     */
187 5
    protected function doImport(Finder $finder, InputInterface $input, OutputInterface $output)
188
    {
189 5
        $exitCode = 0;
190 5
        $host = $input->getArgument('host');
191 5
        $rewriteHost = $input->getOption('rewrite-host');
192 5
        $rewriteTo = $input->getOption('rewrite-to');
193 5
        $this->headerBasicAuth = $input->getOption('headers-basic-auth');
194 5
        $this->customHeaders = $input->getOption('custom-headers');
0 ignored issues
show
Documentation Bug introduced by
It seems like $input->getOption('custom-headers') of type * is incompatible with the declared type array of property $customHeaders.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
195 5
        if ($rewriteTo === $this->getDefinition()->getOption('rewrite-to')->getDefault()) {
196 5
            $rewriteTo = $host;
197 5
        }
198 5
        $noOverwrite = $input->getOption('no-overwrite');
199
200 5
        $this->importPaths($finder, $output, $host, $rewriteHost, $rewriteTo, $noOverwrite);
201
202
        // Error exit
203 4
        if (empty($this->errors)) {
204
            // No errors
205 3
            $this->logger->info('No errors');
206 3
        } else {
207
            // Yes, there was errors
208 1
            $this->logger->error(
209 1
                'There were import errors',
210
                [
211 1
                    'errorCount' => count($this->errors),
212 1
                    'errors' => $this->errors
213 1
                ]
214 1
            );
215 1
            $exitCode = 1;
216
        }
217 4
        return $exitCode;
218
    }
219
220
    /**
221
     * @param Finder          $finder      finder primmed with files to import
222
     * @param OutputInterface $output      output interfac
223
     * @param string          $host        host to import into
224
     * @param string          $rewriteHost string to replace with value from $rewriteTo during loading
225
     * @param string          $rewriteTo   string to replace value from $rewriteHost with during loading
226
     * @param boolean         $noOverwrite should we not overwrite existing records?
227
     *
228
     * @return void
229
     *
230
     * @throws MissingTargetException
231
     */
232 5
    protected function importPaths(
233 1
        Finder $finder,
234
        OutputInterface $output,
235
        $host,
236
        $rewriteHost,
237
        $rewriteTo,
238
        $noOverwrite = false
239
    ) {
240
        /** @var SplFileInfo $file */
241 5
        foreach ($finder as $file) {
242 5
            $doc = $this->frontMatter->parse($file->getContents());
243
244 5
            $this->logger->info("Loading data from ${file}");
245
246 5
            if (!array_key_exists('target', $doc->getData())) {
247 1
                throw new MissingTargetException('Missing target in \'' . $file . '\'');
248
            }
249
250 4
            $targetUrl = sprintf('%s%s', $host, $doc->getData()['target']);
251
252 4
            $this->importResource(
253 4
                $targetUrl,
254 4
                (string) $file,
255 4
                $output,
256 4
                $doc,
257 4
                $rewriteHost,
258 4
                $rewriteTo,
259
                $noOverwrite
260 4
            );
261 4
        }
262
    }
263
264
    /**
265
     * @param string          $targetUrl   target url to import resource into
266
     * @param string          $file        path to file being loaded
267
     * @param OutputInterface $output      output of the command
268
     * @param Document        $doc         document to load
269
     * @param string          $rewriteHost string to replace with value from $host during loading
270
     * @param string          $rewriteTo   string to replace value from $rewriteHost with during loading
271
     * @param boolean         $noOverwrite should we not overwrite existing records?
272
     *
273
     * @return Promise\PromiseInterface|null
274
     */
275
    protected function importResource(
276
        $targetUrl,
277
        $file,
278
        OutputInterface $output,
279
        Document $doc,
280
        $rewriteHost,
281
        $rewriteTo,
282
        $noOverwrite = false
283
    ) {
284 4
        $content = str_replace($rewriteHost, $rewriteTo, $doc->getContent());
285 4
        $uploadFile = $this->validateUploadFile($doc, $file);
286
287
        $data = [
288 4
            'json'   => $this->parseContent($content, $file),
289 4
            'upload' => $uploadFile,
290 4
            'headers'=> []
291 4
        ];
292
293
        // Authentication or custom headers.
294 4
        if ($this->headerBasicAuth) {
295
            $data['headers']['Authorization'] = 'Basic '. base64_encode($this->headerBasicAuth);
296
        }
297 4
        if ($this->customHeaders) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->customHeaders of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
298
            foreach ($this->customHeaders as $headers) {
299
                list($key, $value) = explode(':', $headers);
300
                $data['headers'][$key] = $value;
301
            }
302
        }
303 4
        if (empty($data['headers'])) {
304 4
            unset($data['headers']);
305 4
        }
306
307
        // skip if no overwriting has been requested
308 4
        if ($noOverwrite) {
309
            $response = $this->client->request('GET', $targetUrl, array_merge($data, ['http_errors' => false]));
310
            if ($response->getStatusCode() == 200) {
311
                $this->logger->info(
312
                    sprintf(
313
                        'Skipping <%s> as "no overwrite" is activated and it does exist.',
314
                        $targetUrl
315
                    )
316
                );
317
                return;
318
            }
319
        }
320
321
        try {
322 4
            if ($uploadFile) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $uploadFile of type false|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
323 1
                unset($this->errors[$file]);
324
                try {
325 1
                    $this->client->request('DELETE', $targetUrl, $data);
326 1
                    $this->logger->info("File deleted: ${targetUrl}");
327 1
                } catch (\Exception $e) {
328
                    $this->logger->error(
329
                        sprintf(
330
                            'Failed to delete <%s> with message \'%s\'',
331
                            $targetUrl,
332
                            $e->getMessage()
333
                        ),
334
                        ['exception' => $e]
335
                    );
336
                }
337 1
            }
338
339 4
            $response = $this->client->request(
340 4
                'PUT',
341 4
                $targetUrl,
342
                $data
343 4
            );
344
345 3
            $this->logger->info('Wrote ' . $response->getHeader('Link')[0]);
346 4
        } catch (\Exception $e) {
347 1
            $this->errors[$file] = $e->getMessage();
348 1
            $this->logger->error(
349 1
                sprintf(
350 1
                    'Failed to write <%s> from \'%s\' with message \'%s\'',
351 1
                    $e->getRequest()->getUri(),
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class Exception as the method getRequest() does only exist in the following sub-classes of Exception: GuzzleHttp\Exception\BadResponseException, GuzzleHttp\Exception\ClientException, GuzzleHttp\Exception\ConnectException, GuzzleHttp\Exception\RequestException, GuzzleHttp\Exception\ServerException, GuzzleHttp\Exception\TooManyRedirectsException. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

abstract class User
{
    /** @return string */
    abstract public function getPassword();
}

class MyUser extends User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different sub-classes of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
352 1
                    $file,
353 1
                    $e->getMessage()
354 1
                ),
355 1
                ['exception' => $e]
356 1
            );
357 1
            if ($output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE) {
358 1
                $this->dumper->dump(
359 1
                    $this->cloner->cloneVar(
360 1
                        $this->parser->parse($e->getResponse()->getBody(), Yaml::PARSE_OBJECT_FOR_MAP)
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class Exception as the method getResponse() does only exist in the following sub-classes of Exception: GuzzleHttp\Exception\BadResponseException, GuzzleHttp\Exception\ClientException, GuzzleHttp\Exception\ConnectException, GuzzleHttp\Exception\RequestException, GuzzleHttp\Exception\ServerException, GuzzleHttp\Exception\TooManyRedirectsException. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

abstract class User
{
    /** @return string */
    abstract public function getPassword();
}

class MyUser extends User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different sub-classes of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
361 1
                    ),
362
                    function ($line, $depth) use ($output) {
363 1
                        if ($depth > 0) {
364 1
                            $output->writeln(
365 1
                                '<error>' . str_pad(str_repeat('  ', $depth) . $line, 140, ' ') . '</error>'
366 1
                            );
367 1
                        }
368 1
                    }
369 1
                );
370 1
            }
371
        }
372 4
    }
373
374
    /**
375
     * parse contents of a file depending on type
376
     *
377
     * @param string $content contents part of file
378
     * @param string $file    full path to file
379
     *
380
     * @return mixed
381
     * @throws UnknownFileTypeException
382
     * @throws ParseException
383
     */
384
    protected function parseContent($content, $file)
385
    {
386 4
        if (substr($file, -5) == '.json') {
387 3
            $data = json_decode($content);
388 3
            if (json_last_error() !== JSON_ERROR_NONE) {
389
                throw new ParseException(
390
                    sprintf(
391
                        '%s in %s',
392
                        json_last_error_msg(),
393
                        $file
394
                    )
395
                );
396
            }
397 4
        } elseif (substr($file, -4) == '.yml') {
398
            try {
399 1
                $data = $this->parser->parse($content);
400 1
            } catch (\Exception $e) {
401
                throw new ParseException(
402
                    sprintf(
403
                        'YAML parse error in file %s, message = %s',
404
                        $file,
405
                        $e->getMessage()
406
                    ),
407
                    0,
408
                    $e
409
                );
410
            }
411 1
        } else {
412
            throw new UnknownFileTypeException($file);
413
        }
414
415 4
        return $data;
416
    }
417
418
    /**
419
     * Checks if file exists and return qualified fileName location
420
     *
421
     * @param Document $doc        Data source for import data
422
     * @param string   $originFile Original full filename used toimport
423
     * @return bool|mixed
424
     */
425
    private function validateUploadFile(Document $doc, $originFile)
426
    {
427 4
        $documentData = $doc->getData();
428
429 4
        if (!array_key_exists('file', $documentData)) {
430 3
            return false;
431
        }
432
433
        // Find file
434 1
        $fileName = dirname($originFile) . DIRECTORY_SEPARATOR . $documentData['file'];
435 1
        $fileName = str_replace('//', '/', $fileName);
436 1
        if (!file_exists($fileName)) {
437
            return false;
438
        }
439
440 1
        return $fileName;
441
    }
442
}
443