Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChartRenderer
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 4
72
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 renderSVG
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 renderWithService
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
6
 renderWithCli
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace MediaWiki\Extension\Chart;
4
5use MediaWiki\Config\ServiceOptions;
6use MediaWiki\Context\RequestContext;
7use MediaWiki\Http\HttpRequestFactory;
8use MediaWiki\Language\FormatterFactory;
9use MediaWiki\Shell\Shell;
10use MediaWiki\Status\Status;
11use MWCryptHash;
12use Psr\Log\LoggerInterface;
13use stdclass;
14
15class ChartRenderer {
16
17    private ServiceOptions $options;
18    private HttpRequestFactory $httpRequestFactory;
19    private FormatterFactory $formatterFactory;
20    private LoggerInterface $logger;
21
22    /**
23     * @internal For use by ServiceWiring
24     */
25    public const CONSTRUCTOR_OPTIONS = [
26        'ChartServiceUrl',
27        'ChartCliPath'
28    ];
29
30    /**
31     * @param ServiceOptions $options
32     * @param HttpRequestFactory $httpRequestFactory
33     * @param FormatterFactory $formatterFactory
34     * @param LoggerInterface $logger
35     */
36    public function __construct(
37        ServiceOptions $options,
38        HttpRequestFactory $httpRequestFactory,
39        FormatterFactory $formatterFactory,
40        LoggerInterface $logger
41    ) {
42        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
43        $this->options = $options;
44        $this->httpRequestFactory = $httpRequestFactory;
45        $this->formatterFactory = $formatterFactory;
46        $this->logger = $logger;
47    }
48
49    /**
50     * Render a chart from a definition object and a tabular data object.
51     *
52     * @param stdclass $chartDef Chart definition, obtained from JCChartContent::getContent()
53     * @param stdclass $tabularData Tabular data, obtained from JCTabularContent::getContent()
54     * @param array{width?:string,height?:string} $options Additional rendering options:
55     *   'width': Width of the chart, in pixels. Overrides width specified in the chart definition
56     *   'height': Height of the chart, in pixels. Overrides height specified in the chart definition.
57     * @return Status A Status object wrapping an SVG string or an error
58     */
59    public function renderSVG( stdclass $chartDef, stdclass $tabularData, array $options = [] ): Status {
60        // Prefix for IDs in the SVG. This has to be unique between charts on the same page, to
61        // prevent ID collisions (T371558). If the same chart with the same data is displayed twice
62        // on the same page, this gives them the same ID prefixes and causes their IDs to collide,
63        // but that doesn't seem to cause a problem in practice.
64        $definitionForHash = json_encode( [ 'format' => $chartDef, 'source' => $tabularData ] );
65        $chartDef = clone $chartDef;
66        $chartDef->idPrefix = 'mw-chart-' . MWCryptHash::hash( $definitionForHash, false );
67
68        if ( $this->options->get( 'ChartServiceUrl' ) !== null ) {
69            return $this->renderWithService( $chartDef, $tabularData, $options );
70        }
71
72        return $this->renderWithCli( $chartDef, $tabularData, $options );
73    }
74
75    private function renderWithService( stdclass $chartDef, stdclass $tabularData, array $options ): Status {
76        $requestData = array_merge( [
77            'definition' => $chartDef,
78            'data' => $tabularData,
79        ], $options );
80
81        $requestOptions = [
82            'method' => 'POST',
83            'postData' => json_encode( $requestData )
84        ];
85        $request = $this->httpRequestFactory->create(
86            $this->options->get( 'ChartServiceUrl' ),
87            $requestOptions,
88            __METHOD__
89        );
90        $request->setHeader( 'Content-Type', 'application/json' );
91
92        $status = $request->execute();
93        if ( !$status->isOK() ) {
94            [ $message, $context ] = $this->formatterFactory->getStatusFormatter( RequestContext::getMain() )
95                ->getPsr3MessageAndContext( $status );
96            $this->logger->error(
97                'Chart service request returned error: {error}',
98                [ 'error' => $message ] + $context
99            );
100            return Status::newFatal( 'chart-error-rendering-error' );
101        }
102        $response = $request->getContent();
103        return Status::newGood( $response );
104    }
105
106    private function renderWithCli( stdclass $chartDef, stdclass $tabularData, array $options ): Status {
107        if ( Shell::isDisabled() ) {
108            return Status::newFatal( 'chart-error-shell-disabled' );
109        }
110
111        $dataPath = tempnam( \wfTempDir(), 'data-json' );
112        file_put_contents( $dataPath, json_encode( [
113            'definition' => $chartDef,
114            'data' => $tabularData,
115            ...$options
116        ] ) );
117
118        $result = Shell::command(
119            'node',
120            $this->options->get( 'ChartCliPath' ),
121            $dataPath,
122            '-'
123         )
124            ->execute();
125
126        $error = $result->getStderr();
127        if ( $error ) {
128            $this->logger->error(
129                'Chart shell command returned error: {error}',
130                [ 'error' => $error ]
131            );
132
133            // @todo tracking category
134            $status = Status::newFatal( 'chart-error-rendering-error' );
135        } else {
136            $svg = $result->getStdout();
137            $status = Status::newGood( $svg );
138        }
139
140        unlink( $dataPath );
141        return $status;
142    }
143}