Skip to content

Commit 01dff42

Browse files
authored
Add provider configuration validation tooling (#89)
* Add provider configuration validation tooling * Validate providers in CI
1 parent 926f1b9 commit 01dff42

6 files changed

Lines changed: 280 additions & 2 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ jobs:
9595
- name: Run phpcs
9696
run: composer cs-check
9797

98+
- name: Validate providers
99+
run: composer validate-providers
100+
98101
- name: Run link checker
99102
run: |
100103
sudo npm install -g markdown-link-check

‎bin/validate-providers.php‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/php -q
2+
<?php
3+
4+
$options = [
5+
__DIR__ . '/../vendor/autoload.php',
6+
__DIR__ . '/vendor/autoload.php',
7+
];
8+
if (!empty($_SERVER['PWD'])) {
9+
array_unshift($options, $_SERVER['PWD'] . '/vendor/autoload.php');
10+
}
11+
12+
foreach ($options as $file) {
13+
if (file_exists($file)) {
14+
define('MEDIA_EMBED_COMPOSER_INSTALL', $file);
15+
break;
16+
}
17+
}
18+
require MEDIA_EMBED_COMPOSER_INSTALL;
19+
20+
$path = $argv[1] ?? dirname(__DIR__) . '/data/stubs.php';
21+
$providers = include $path;
22+
if (!is_array($providers)) {
23+
fwrite(STDERR, 'Provider file must return an array.' . PHP_EOL);
24+
exit(1);
25+
}
26+
27+
$validator = new \MediaEmbed\Provider\ProviderValidator();
28+
$errors = $validator->validate($providers);
29+
if (!$errors) {
30+
echo 'Provider configuration is valid.' . PHP_EOL;
31+
exit(0);
32+
}
33+
34+
foreach ($errors as $error) {
35+
fwrite(STDERR, $error . PHP_EOL);
36+
}
37+
38+
exit(1);

‎composer.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
}
4242
},
4343
"bin": [
44-
"bin/generate-docs"
44+
"bin/generate-docs",
45+
"bin/validate-providers.php"
4546
],
4647
"config": {
4748
"allow-plugins": {
@@ -56,6 +57,7 @@
5657
"stan": "phpstan analyse",
5758
"stan-setup": "cp composer.json composer.backup && composer require --dev phpstan/phpstan:^1.0.0 && mv composer.backup composer.json",
5859
"test": "phpunit",
59-
"test-coverage": "phpunit --log-junit tmp/coverage/unitreport.xml --coverage-html tmp/coverage --coverage-clover tmp/coverage/coverage.xml"
60+
"test-coverage": "phpunit --log-junit tmp/coverage/unitreport.xml --coverage-html tmp/coverage --coverage-clover tmp/coverage/coverage.xml",
61+
"validate-providers": "php bin/validate-providers.php"
6062
}
6163
}

‎docs/README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,3 +620,8 @@ Update list of services in `docs/supported.md` with
620620
```
621621
bin/generate-docs
622622
```
623+
624+
Validate provider configuration data with
625+
```
626+
composer validate-providers
627+
```

‎src/Provider/ProviderValidator.php‎

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MediaEmbed\Provider;
6+
7+
use URLify;
8+
9+
/**
10+
* Validates provider configuration arrays.
11+
*/
12+
final class ProviderValidator {
13+
14+
/**
15+
* @var array<string>
16+
*/
17+
private const REQUIRED_FIELDS = [
18+
'name',
19+
'website',
20+
'url-match',
21+
'embed-width',
22+
'embed-height',
23+
'iframe-player',
24+
];
25+
26+
/**
27+
* Validate provider configuration arrays.
28+
*
29+
* @param array<array<string, mixed>> $providers
30+
* @return array<string> Validation errors.
31+
*/
32+
public function validate(array $providers): array {
33+
$errors = [];
34+
$slugs = [];
35+
36+
foreach ($providers as $index => $provider) {
37+
$label = $this->label($provider, $index);
38+
$this->validateRequiredFields($provider, $label, $errors);
39+
$this->validateDimensions($provider, $label, $errors);
40+
$this->validateUrlMatches($provider, $label, $errors);
41+
$this->validateFetchMatch($provider, $label, $errors);
42+
$this->validateSlug($provider, $label, $slugs, $errors);
43+
}
44+
45+
return $errors;
46+
}
47+
48+
/**
49+
* @param array<string, mixed> $provider
50+
* @param string|int $index
51+
* @return string
52+
*/
53+
private function label(array $provider, string|int $index): string {
54+
$name = $provider['name'] ?? null;
55+
if (is_string($name) && $name !== '') {
56+
return $name;
57+
}
58+
59+
return 'provider #' . $index;
60+
}
61+
62+
/**
63+
* @param array<string, mixed> $provider
64+
* @param string $label
65+
* @param array<string> $errors
66+
* @return void
67+
*/
68+
private function validateRequiredFields(array $provider, string $label, array &$errors): void {
69+
foreach (self::REQUIRED_FIELDS as $field) {
70+
if (!array_key_exists($field, $provider) || $provider[$field] === '' || $provider[$field] === []) {
71+
$errors[] = $label . ': missing required field "' . $field . '"';
72+
}
73+
}
74+
}
75+
76+
/**
77+
* @param array<string, mixed> $provider
78+
* @param string $label
79+
* @param array<string> $errors
80+
* @return void
81+
*/
82+
private function validateDimensions(array $provider, string $label, array &$errors): void {
83+
foreach (['embed-width', 'embed-height'] as $field) {
84+
if (!array_key_exists($field, $provider)) {
85+
continue;
86+
}
87+
if (!is_int($provider[$field]) && !is_string($provider[$field])) {
88+
$errors[] = $label . ': field "' . $field . '" must be an integer or string';
89+
}
90+
}
91+
}
92+
93+
/**
94+
* @param array<string, mixed> $provider
95+
* @param string $label
96+
* @param array<string> $errors
97+
* @return void
98+
*/
99+
private function validateUrlMatches(array $provider, string $label, array &$errors): void {
100+
if (!array_key_exists('url-match', $provider)) {
101+
return;
102+
}
103+
104+
$patterns = is_array($provider['url-match']) ? $provider['url-match'] : [$provider['url-match']];
105+
foreach ($patterns as $pattern) {
106+
if (!is_string($pattern) || $pattern === '') {
107+
$errors[] = $label . ': url-match entries must be non-empty strings';
108+
109+
continue;
110+
}
111+
$this->validateRegex($pattern, $label, 'url-match', $errors);
112+
}
113+
}
114+
115+
/**
116+
* @param array<string, mixed> $provider
117+
* @param string $label
118+
* @param array<string> $errors
119+
* @return void
120+
*/
121+
private function validateFetchMatch(array $provider, string $label, array &$errors): void {
122+
if (!array_key_exists('fetch-match', $provider)) {
123+
return;
124+
}
125+
126+
if (!is_string($provider['fetch-match']) || $provider['fetch-match'] === '') {
127+
$errors[] = $label . ': fetch-match must be a non-empty string';
128+
129+
return;
130+
}
131+
132+
$this->validateRegex($provider['fetch-match'], $label, 'fetch-match', $errors);
133+
}
134+
135+
/**
136+
* @param string $pattern
137+
* @param string $label
138+
* @param string $field
139+
* @param array<string> $errors
140+
* @return void
141+
*/
142+
private function validateRegex(string $pattern, string $label, string $field, array &$errors): void {
143+
set_error_handler(static function (): bool {
144+
return true;
145+
});
146+
$result = preg_match('~' . $pattern . '~imu', '');
147+
restore_error_handler();
148+
149+
if ($result === false) {
150+
$errors[] = $label . ': invalid regex in "' . $field . '"';
151+
}
152+
}
153+
154+
/**
155+
* @param array<string, mixed> $provider
156+
* @param string $label
157+
* @param array<string, string> $slugs
158+
* @param array<string> $errors
159+
* @return void
160+
*/
161+
private function validateSlug(array $provider, string $label, array &$slugs, array &$errors): void {
162+
$name = $provider['name'] ?? null;
163+
$slug = $provider['slug'] ?? null;
164+
if (!is_string($slug) || $slug === '') {
165+
if (!is_string($name) || $name === '') {
166+
return;
167+
}
168+
$slug = URLify::filter($name);
169+
}
170+
171+
if (isset($slugs[$slug])) {
172+
$errors[] = $label . ': duplicate slug "' . $slug . '" already used by ' . $slugs[$slug];
173+
174+
return;
175+
}
176+
177+
$slugs[$slug] = $label;
178+
}
179+
180+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
namespace MediaEmbed\Test\Provider;
4+
5+
use MediaEmbed\Provider\ProviderValidator;
6+
use PHPUnit\Framework\TestCase;
7+
8+
class ProviderValidatorTest extends TestCase {
9+
10+
public function testValidateDefaultProviders(): void {
11+
$providers = include dirname(__DIR__, 2) . '/data/stubs.php';
12+
13+
$validator = new ProviderValidator();
14+
15+
$this->assertSame([], $validator->validate($providers));
16+
}
17+
18+
public function testValidateReportsInvalidProviderData(): void {
19+
$providers = [
20+
[
21+
'name' => 'Broken',
22+
'website' => 'https://broken.example.com',
23+
'url-match' => ['broken\\.example\\.com/([0-9]+', ''],
24+
'embed-width' => [],
25+
'embed-height' => '360',
26+
'iframe-player' => '',
27+
'fetch-match' => '(',
28+
],
29+
[
30+
'name' => 'Broken',
31+
'website' => 'https://other.example.com',
32+
'url-match' => 'other\\.example\\.com/([0-9]+)',
33+
'embed-width' => 640,
34+
'embed-height' => 360,
35+
'iframe-player' => '//other.example.com/embed/$2',
36+
],
37+
];
38+
39+
$validator = new ProviderValidator();
40+
$errors = $validator->validate($providers);
41+
42+
$this->assertContains('Broken: missing required field "iframe-player"', $errors);
43+
$this->assertContains('Broken: field "embed-width" must be an integer or string', $errors);
44+
$this->assertContains('Broken: invalid regex in "url-match"', $errors);
45+
$this->assertContains('Broken: url-match entries must be non-empty strings', $errors);
46+
$this->assertContains('Broken: invalid regex in "fetch-match"', $errors);
47+
$this->assertContains('Broken: duplicate slug "broken" already used by Broken', $errors);
48+
}
49+
50+
}

0 commit comments

Comments
 (0)