Completed
Push — feature/sentry-and-update ( 08707d )
by Narcotic
03:24
created

ImportCommand   B

Complexity

Total Complexity 27

Size/Duplication

Total Lines 394
Duplicated Lines 0 %

Coupling/Cohesion

Components 2
Dependencies 16

Test Coverage

Coverage 0%

Importance

Changes 0
Metric Value
wmc 27
c 0
b 0
f 0
lcom 2
cbo 16
dl 0
loc 394
ccs 0
cts 193
cp 0
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
A parseContent() 0 21 4
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\UnknownFileTypeException;
15
use Graviton\ImportExport\Service\HttpClient;
16
use Monolog\Logger;
17
use Symfony\Component\Console\Input\InputArgument;
18
use Symfony\Component\Console\Input\InputOption;
19
use Symfony\Component\Console\Input\InputInterface;
20
use Symfony\Component\Console\Output\OutputInterface;
21
use Symfony\Component\Finder\Finder;
22
use Symfony\Component\Yaml\Parser;
23
use Symfony\Component\VarDumper\Cloner\VarCloner;
24
use Symfony\Component\VarDumper\Dumper\CliDumper as Dumper;
25
use GuzzleHttp\Promise;
26
use Symfony\Component\Finder\SplFileInfo;
27
use Webuni\FrontMatter\FrontMatter;
28
use Webuni\FrontMatter\Document;
29
30
/**
31
 * @author   List of contributors <https://github.com/libgraviton/import-export/graphs/contributors>
32
 * @license  http://opensource.org/licenses/gpl-license.php GNU Public License
33
 * @link     http://swisscom.ch
34
 */
35
class ImportCommand extends ImportCommandAbstract
36
{
37
38
    /**
39
     * @var HttpClient
40
     */
41
    private $client;
42
43
    /**
44
     * @var FrontMatter
45
     */
46
    private $frontMatter;
47
48
    /**
49
     * @var Parser
50
     */
51
    private $parser;
52
53
    /**
54
     * @var VarCloner
55
     */
56
    private $cloner;
57
58
    /**
59
     * @var Dumper
60
     */
61
    private $dumper;
62
63
    /**
64
     * Count of errors
65
     * @var array
66
     */
67
    private $errors = [];
68
69
    /**
70
     * Header basic auth
71
     * @var string
72
     */
73
    private $headerBasicAuth;
74
75
    /**
76
     * Header for custom variables
77
     * @var array
78
     */
79
    private $customHeaders;
80
81
    /**
82
     * @param Logger      $logger      logger
83
     * @param HttpClient  $client      Grv HttpClient guzzle http client
84
     * @param Finder      $finder      symfony/finder instance
85
     * @param FrontMatter $frontMatter frontmatter parser
86
     * @param Parser      $parser      yaml/json parser
87
     * @param VarCloner   $cloner      var cloner for dumping reponses
88
     * @param Dumper      $dumper      dumper for outputing responses
89
     */
90
    public function __construct(
91
        Logger $logger,
92
        HttpClient $client,
93
        Finder $finder,
94
        FrontMatter $frontMatter,
95
        Parser $parser,
96
        VarCloner $cloner,
97
        Dumper $dumper
98
    ) {
99
        parent::__construct(
100
            $logger,
101
            $finder
102
        );
103
        $this->client = $client;
104
        $this->frontMatter = $frontMatter;
105
        $this->parser = $parser;
106
        $this->cloner = $cloner;
107
        $this->dumper = $dumper;
108
    }
109
110
    /**
111
     * Configures the current command.
112
     *
113
     * @return void
114
     */
115
    protected function configure()
116
    {
117
        $this
118
            ->setName('graviton:import')
119
            ->setDescription('Import files from a folder or file.')
120
            ->addOption(
121
                'rewrite-host',
122
                'r',
123
                InputOption::VALUE_OPTIONAL,
124
                'Replace the value of this option with the <host> value before importing.',
125
                'http://localhost'
126
            )
127
            ->addOption(
128
                'rewrite-to',
129
                't',
130
                InputOption::VALUE_OPTIONAL,
131
                'String to use as the replacement value for the [REWRITE-HOST] string.',
132
                '<host>'
133
            )
134
            ->addOption(
135
                'sync-requests',
136
                's',
137
                InputOption::VALUE_NONE,
138
                'Send requests synchronously'
139
            )
140
            ->addOption(
141
                'no-overwrite',
142
                'o',
143
                InputOption::VALUE_NONE,
144
                'If set, we will check for record existence and not overwrite existing ones.'
145
            )
146
            ->addOption(
147
                'headers-basic-auth',
148
                'a',
149
                InputOption::VALUE_OPTIONAL,
150
                'Header user:password for Basic auth'
151
            )
152
            ->addOption(
153
                'custom-headers',
154
                'c',
155
                InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY,
156
                'Custom Header variable(s), -c{key:value} and multiple is optional.'
157
            )
158
            ->addOption(
159
                'input-file',
160
                'i',
161
                InputOption::VALUE_REQUIRED,
162
                'If provided, the list of files to load will be loaded from this file, one file per line.'
163
            )
164
            ->addArgument(
165
                'host',
166
                InputArgument::REQUIRED,
167
                'Protocol and host to load data into (ie. https://graviton.nova.scapp.io)'
168
            )
169
            ->addArgument(
170
                'file',
171
                InputArgument::IS_ARRAY,
172
                'Directories or files to load'
173
            );
174
    }
175
176
    /**
177
     * Executes the current command.
178
     *
179
     * @param Finder          $finder Finder
180
     * @param InputInterface  $input  User input on console
181
     * @param OutputInterface $output Output of the command
182
     *
183
     * @return integer
184
     */
185
    protected function doImport(Finder $finder, InputInterface $input, OutputInterface $output)
186
    {
187
        $exitCode = 0;
188
        $host = $input->getArgument('host');
189
        $rewriteHost = $input->getOption('rewrite-host');
190
        $rewriteTo = $input->getOption('rewrite-to');
191
        $this->headerBasicAuth = $input->getOption('headers-basic-auth');
192
        $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...
193
        if ($rewriteTo === $this->getDefinition()->getOption('rewrite-to')->getDefault()) {
194
            $rewriteTo = $host;
195
        }
196
        $noOverwrite = $input->getOption('no-overwrite');
197
198
        $this->importPaths($finder, $output, $host, $rewriteHost, $rewriteTo, $noOverwrite);
199
200
        // Error exit
201
        if (empty($this->errors)) {
202
            // No errors
203
            $this->logger->info('No errors');
204
        } else {
205
            // Yes, there was errors
206
            $this->logger->error(
207
                'There were import errors',
208
                [
209
                    'errorCount' => count($this->errors),
210
                    'errors' => $this->errors
211
                ]
212
            );
213
            $exitCode = 1;
214
        }
215
        return $exitCode;
216
    }
217
218
    /**
219
     * @param Finder          $finder      finder primmed with files to import
220
     * @param OutputInterface $output      output interfac
221
     * @param string          $host        host to import into
222
     * @param string          $rewriteHost string to replace with value from $rewriteTo during loading
223
     * @param string          $rewriteTo   string to replace value from $rewriteHost with during loading
224
     * @param boolean         $noOverwrite should we not overwrite existing records?
225
     *
226
     * @return void
227
     *
228
     * @throws MissingTargetException
229
     */
230
    protected function importPaths(
231
        Finder $finder,
232
        OutputInterface $output,
233
        $host,
234
        $rewriteHost,
235
        $rewriteTo,
236
        $noOverwrite = false
237
    ) {
238
        /** @var SplFileInfo $file */
239
        foreach ($finder as $file) {
240
            $doc = $this->frontMatter->parse($file->getContents());
241
242
            $this->logger->info("Loading data from ${file}");
243
244
            if (!array_key_exists('target', $doc->getData())) {
245
                throw new MissingTargetException('Missing target in \'' . $file . '\'');
246
            }
247
248
            $targetUrl = sprintf('%s%s', $host, $doc->getData()['target']);
249
250
            $this->importResource(
251
                $targetUrl,
252
                (string) $file,
253
                $output,
254
                $doc,
255
                $rewriteHost,
256
                $rewriteTo,
257
                $noOverwrite
258
            );
259
        }
260
    }
261
262
    /**
263
     * @param string          $targetUrl   target url to import resource into
264
     * @param string          $file        path to file being loaded
265
     * @param OutputInterface $output      output of the command
266
     * @param Document        $doc         document to load
267
     * @param string          $rewriteHost string to replace with value from $host during loading
268
     * @param string          $rewriteTo   string to replace value from $rewriteHost with during loading
269
     * @param boolean         $noOverwrite should we not overwrite existing records?
270
     *
271
     * @return Promise\PromiseInterface|null
272
     */
273
    protected function importResource(
274
        $targetUrl,
275
        $file,
276
        OutputInterface $output,
277
        Document $doc,
278
        $rewriteHost,
279
        $rewriteTo,
280
        $noOverwrite = false
281
    ) {
282
        $content = str_replace($rewriteHost, $rewriteTo, $doc->getContent());
283
        $uploadFile = $this->validateUploadFile($doc, $file);
284
285
        $data = [
286
            'json'   => $this->parseContent($content, $file),
287
            'upload' => $uploadFile,
288
            'headers'=> []
289
        ];
290
291
        // Authentication or custom headers.
292
        if ($this->headerBasicAuth) {
293
            $data['headers']['Authorization'] = 'Basic '. base64_encode($this->headerBasicAuth);
294
        }
295
        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...
296
            foreach ($this->customHeaders as $headers) {
297
                list($key, $value) = explode(':', $headers);
298
                $data['headers'][$key] = $value;
299
            }
300
        }
301
        if (empty($data['headers'])) {
302
            unset($data['headers']);
303
        }
304
305
        // skip if no overwriting has been requested
306
        if ($noOverwrite) {
307
            $response = $this->client->request('GET', $targetUrl, array_merge($data, ['http_errors' => false]));
308
            if ($response->getStatusCode() == 200) {
309
                $this->logger->info(
310
                    sprintf(
311
                        'Skipping <%s> as "no overwrite" is activated and it does exist.',
312
                        $targetUrl
313
                    )
314
                );
315
                return;
316
            }
317
        }
318
319
        try {
320
            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...
321
                unset($this->errors[$file]);
322
                try {
323
                    $this->client->request('DELETE', $targetUrl, $data);
324
                    $this->logger->info("File deleted: ${targetUrl}");
325
                } catch (\Exception $e) {
326
                    $this->logger->error(
327
                        sprintf(
328
                            'Failed to delete <%s> with message \'%s\'',
329
                            $targetUrl,
330
                            $e->getMessage()
331
                        ),
332
                        ['exception' => $e]
333
                    );
334
                }
335
            }
336
337
            $response = $this->client->request(
338
                'PUT',
339
                $targetUrl,
340
                $data
341
            );
342
343
            $this->logger->info('Wrote ' . $response->getHeader('Link')[0]);
344
        } catch (\Exception $e) {
345
            $this->errors[$file] = $e->getMessage();
346
            $this->logger->error(
347
                sprintf(
348
                    'Failed to write <%s> from \'%s\' with message \'%s\'',
349
                    $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...
350
                    $file,
351
                    $e->getMessage()
352
                ),
353
                ['exception' => $e]
354
            );
355
            if ($output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE) {
356
                $this->dumper->dump(
357
                    $this->cloner->cloneVar(
358
                        $this->parser->parse($e->getResponse()->getBody(), false, false, true)
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...
359
                    ),
360
                    function ($line, $depth) use ($output) {
361
                        if ($depth > 0) {
362
                            $output->writeln(
363
                                '<error>' . str_pad(str_repeat('  ', $depth) . $line, 140, ' ') . '</error>'
364
                            );
365
                        }
366
                    }
367
                );
368
            }
369
        }
370
    }
371
372
    /**
373
     * parse contents of a file depending on type
374
     *
375
     * @param string $content contents part of file
376
     * @param string $file    full path to file
377
     *
378
     * @return mixed
379
     * @throws UnknownFileTypeException
380
     * @throws JsonParseException
381
     */
382
    protected function parseContent($content, $file)
383
    {
384
        if (substr($file, -5) == '.json') {
385
            $data = json_decode($content);
386
            if (json_last_error() !== JSON_ERROR_NONE) {
387
                throw new JsonParseException(
388
                    sprintf(
389
                        '%s in %s',
390
                        json_last_error_msg(),
391
                        $file
392
                    )
393
                );
394
            }
395
        } elseif (substr($file, -4) == '.yml') {
396
            $data = $this->parser->parse($content);
397
        } else {
398
            throw new UnknownFileTypeException($file);
399
        }
400
401
        return $data;
402
    }
403
404
    /**
405
     * Checks if file exists and return qualified fileName location
406
     *
407
     * @param Document $doc        Data source for import data
408
     * @param string   $originFile Original full filename used toimport
409
     * @return bool|mixed
410
     */
411
    private function validateUploadFile(Document $doc, $originFile)
412
    {
413
        $documentData = $doc->getData();
414
415
        if (!array_key_exists('file', $documentData)) {
416
            return false;
417
        }
418
419
        // Find file
420
        $fileName = dirname($originFile) . DIRECTORY_SEPARATOR . $documentData['file'];
421
        $fileName = str_replace('//', '/', $fileName);
422
        if (!file_exists($fileName)) {
423
            return false;
424
        }
425
426
        return $fileName;
427
    }
428
}
429