Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
92.42% |
61 / 66 |
|
77.78% |
7 / 9 |
CRAP | |
0.00% |
0 / 1 |
| UsageStatisticsSchema | |
92.42% |
61 / 66 |
|
77.78% |
7 / 9 |
36.56 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
11 | |||
| load | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| fromArray | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| readShippedSchema | |
60.00% |
6 / 10 |
|
0.00% |
0 / 1 |
6.60 | |||
| parseMetric | |
96.67% |
29 / 30 |
|
0.00% |
0 / 1 |
14 | |||
| application | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| version | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| definitions | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| get | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | /** |
| 5 | * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors |
| 6 | * SPDX-License-Identifier: AGPL-3.0-or-later |
| 7 | */ |
| 8 | |
| 9 | namespace OCA\Libresign\Service\UsageStatistics; |
| 10 | |
| 11 | use OCA\Libresign\AppInfo\Application; |
| 12 | use OCA\Libresign\Service\UsageStatistics\Model\MetricDefinition; |
| 13 | |
| 14 | /** |
| 15 | * The versioned schema shipped in appinfo/usage-statistics/. The checks |
| 16 | * mirror the usage statistics server, so a schema it would refuse fails here. |
| 17 | */ |
| 18 | final class UsageStatisticsSchema { |
| 19 | public const VERSION = 1; |
| 20 | |
| 21 | private const NAME_PATTERN = '/^[A-Za-z0-9_.:-]+$/D'; |
| 22 | private const METRIC_FIELDS = ['category', 'key', 'type', 'kind', 'aggregation', 'description', 'required']; |
| 23 | private const TYPES = ['integer', 'number', 'boolean', 'string']; |
| 24 | private const KINDS = ['snapshot', 'period', 'counter', 'categorical']; |
| 25 | private const AGGREGATIONS = ['distribution', 'numerical', 'none']; |
| 26 | private const MAX_METRICS = 256; |
| 27 | private const MAX_DESCRIPTION_BYTES = 512; |
| 28 | |
| 29 | private readonly string $application; |
| 30 | private readonly int $version; |
| 31 | /** @var array<string, MetricDefinition> */ |
| 32 | private array $definitions = []; |
| 33 | |
| 34 | /** |
| 35 | * @param array<mixed>|null $data a decoded schema; null reads the one shipped with the app |
| 36 | */ |
| 37 | public function __construct(?array $data = null) { |
| 38 | $data ??= self::readShippedSchema(); |
| 39 | |
| 40 | $unknownFields = array_diff(array_keys($data), ['application', 'schemaVersion', 'metrics']); |
| 41 | if ($unknownFields !== []) { |
| 42 | throw new UsageStatisticsException('Unknown schema field: ' . implode(', ', $unknownFields)); |
| 43 | } |
| 44 | if (($data['application'] ?? null) !== Application::APP_ID) { |
| 45 | throw new UsageStatisticsException('Schema application must be ' . Application::APP_ID); |
| 46 | } |
| 47 | $version = $data['schemaVersion'] ?? null; |
| 48 | if (!is_int($version) || $version < 1) { |
| 49 | throw new UsageStatisticsException('Schema version must be a positive integer'); |
| 50 | } |
| 51 | $metrics = $data['metrics'] ?? null; |
| 52 | if (!is_array($metrics) || !array_is_list($metrics) || $metrics === [] || count($metrics) > self::MAX_METRICS) { |
| 53 | throw new UsageStatisticsException('Schema must define between 1 and ' . self::MAX_METRICS . ' metrics'); |
| 54 | } |
| 55 | |
| 56 | foreach ($metrics as $metric) { |
| 57 | $definition = self::parseMetric($metric); |
| 58 | if (isset($this->definitions[$definition->id()])) { |
| 59 | throw new UsageStatisticsException('Duplicate metric: ' . $definition->id()); |
| 60 | } |
| 61 | $this->definitions[$definition->id()] = $definition; |
| 62 | } |
| 63 | $this->application = Application::APP_ID; |
| 64 | $this->version = $version; |
| 65 | } |
| 66 | |
| 67 | public static function load(): self { |
| 68 | return new self(); |
| 69 | } |
| 70 | |
| 71 | public static function fromArray(array $data): self { |
| 72 | return new self($data); |
| 73 | } |
| 74 | |
| 75 | private static function readShippedSchema(): array { |
| 76 | $path = dirname(__DIR__, 3) . '/appinfo/usage-statistics/schema-v' . self::VERSION . '.json'; |
| 77 | $content = is_readable($path) ? file_get_contents($path) : false; |
| 78 | if ($content === false) { |
| 79 | throw new UsageStatisticsException('Usage statistics schema not found: ' . $path); |
| 80 | } |
| 81 | try { |
| 82 | $data = json_decode($content, true, 16, JSON_THROW_ON_ERROR); |
| 83 | } catch (\JsonException $e) { |
| 84 | throw new UsageStatisticsException('Usage statistics schema is not valid JSON', 0, $e); |
| 85 | } |
| 86 | if (!is_array($data)) { |
| 87 | throw new UsageStatisticsException('Usage statistics schema must be a JSON object'); |
| 88 | } |
| 89 | return $data; |
| 90 | } |
| 91 | |
| 92 | private static function parseMetric(mixed $metric): MetricDefinition { |
| 93 | if (!is_array($metric)) { |
| 94 | throw new UsageStatisticsException('Each metric must be an object'); |
| 95 | } |
| 96 | $fields = array_keys($metric); |
| 97 | sort($fields); |
| 98 | $expected = self::METRIC_FIELDS; |
| 99 | sort($expected); |
| 100 | if ($fields !== $expected) { |
| 101 | throw new UsageStatisticsException('A metric must have exactly the fields: ' . implode(', ', self::METRIC_FIELDS)); |
| 102 | } |
| 103 | foreach (['category', 'key'] as $name) { |
| 104 | if (!is_string($metric[$name]) || preg_match(self::NAME_PATTERN, $metric[$name]) !== 1) { |
| 105 | throw new UsageStatisticsException('Invalid metric ' . $name); |
| 106 | } |
| 107 | } |
| 108 | if (!in_array($metric['type'], self::TYPES, true) |
| 109 | || !in_array($metric['kind'], self::KINDS, true) |
| 110 | || !in_array($metric['aggregation'], self::AGGREGATIONS, true)) { |
| 111 | throw new UsageStatisticsException('Invalid type, kind or aggregation for ' . $metric['category'] . '.' . $metric['key']); |
| 112 | } |
| 113 | if ($metric['aggregation'] === 'numerical' && !in_array($metric['type'], ['integer', 'number'], true)) { |
| 114 | throw new UsageStatisticsException('Numerical aggregation requires a numeric type'); |
| 115 | } |
| 116 | if (!is_string($metric['description']) || strlen($metric['description']) > self::MAX_DESCRIPTION_BYTES) { |
| 117 | throw new UsageStatisticsException('Invalid metric description'); |
| 118 | } |
| 119 | if (!is_bool($metric['required'])) { |
| 120 | throw new UsageStatisticsException('Metric required flag must be a boolean'); |
| 121 | } |
| 122 | |
| 123 | return new MetricDefinition( |
| 124 | $metric['category'], |
| 125 | $metric['key'], |
| 126 | $metric['type'], |
| 127 | $metric['kind'], |
| 128 | $metric['aggregation'], |
| 129 | $metric['description'], |
| 130 | $metric['required'], |
| 131 | ); |
| 132 | } |
| 133 | |
| 134 | public function application(): string { |
| 135 | return $this->application; |
| 136 | } |
| 137 | |
| 138 | public function version(): int { |
| 139 | return $this->version; |
| 140 | } |
| 141 | |
| 142 | /** @return list<MetricDefinition> */ |
| 143 | public function definitions(): array { |
| 144 | return array_values($this->definitions); |
| 145 | } |
| 146 | |
| 147 | public function get(string $metricId): MetricDefinition { |
| 148 | return $this->definitions[$metricId] |
| 149 | ?? throw new UsageStatisticsException('Unknown usage statistics metric: ' . $metricId); |
| 150 | } |
| 151 | } |