Skip to content

Commit ec233a0

Browse files
authored
Merge pull request #18 from ingenerator/feat-1.x-static-assets
Add StaticAssetUrlProvider to provide URLs for assets in dev/prod
2 parents 9264f6d + fb36a2e commit ec233a0

File tree

4 files changed

+268
-2
lines changed

4 files changed

+268
-2
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
### Unreleased
22

3+
### v1.3.0 (2020-01-16)
4+
5+
* Add StaticAssetUrlProvider to provide simple cache-busted local URLs for CSS etc in local
6+
dev or remote (e.g. cloud storage / s3) urls in production.
7+
38
### v1.2.1 (2019-11-15)
49

510
* Allow DeploymentConfig->map() to return values in standalone environment
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<?php
2+
3+
4+
namespace Ingenerator\PHPUtils\Assets;
5+
6+
7+
use InvalidArgumentException;
8+
use RuntimeException;
9+
10+
/**
11+
* Simple / basic provider for CSS, JS, image etc asset URLs supporting cache-busting in dev and remote hosting in prod
12+
*
13+
* This class provides a very simple mechanism for generating URLs to a site's inbuilt CSS/JS/etc. It operates in two
14+
* modes.
15+
*
16+
* In `local` (developer workstation) mode, it assumes asset files are reachable on the same web host as the main site,
17+
* with the files located below the webserver's document root. Assets are served with a `?v=...` querystring based on
18+
* the file modification time, providing automatic cachebusting during development. Assets that don't exist will throw
19+
* an exception to help identify typos / incorrect relative urls during development.
20+
*
21+
* In `remote` (CI / qa / production) mode, it assumes asset files have been uploaded to a remote file hosting service
22+
* (google cloud / s3 / etc) during the build process. The application prefixes all asset URLs with a host/path prefix
23+
* pointing to that service, or to a CDN or similar. Usually this path prefix - set at build time - will contain an SHA
24+
* or similar identifier so that the assets are in sync with the application.
25+
*
26+
* A build script should therefore:
27+
*
28+
* * Compile the assets as required
29+
* * Choose a suitable remote hosting path for this specific version of the assets
30+
* * Upload them to the remote hosting in the versioned path
31+
* * Create a config file for the application containing the URL prefix - e.g.
32+
* `<?php return 'http://my.cool.cdn/project/version-a923';`
33+
* this file should then be deployed alongside the application.
34+
*
35+
* All this class does is read that file to get the URL prefix, and concat it onto the front of every asset path.
36+
*
37+
*/
38+
class StaticAssetUrlProvider
39+
{
40+
const MODE_LOCAL = 'local';
41+
const MODE_REMOTE = 'remote';
42+
43+
/**
44+
* @var string
45+
*/
46+
protected $local_asset_path;
47+
48+
/**
49+
* @var string
50+
*/
51+
protected $mode;
52+
53+
/**
54+
* @var string
55+
*/
56+
protected $remote_asset_url;
57+
58+
/**
59+
* @param string $mode local or remote mode (see above) - commonly toggle based on runtime environment
60+
* @param string $local_asset_path base path on disk that all assets are relative to (in development)
61+
* @param string $asset_base_url_file path to a php file generated at build time that returns the URL host/path prefix for remote mode
62+
*/
63+
public function __construct(
64+
string $mode,
65+
string $local_asset_path,
66+
string $asset_base_url_file
67+
) {
68+
if ( ! in_array($mode, [static::MODE_LOCAL, static::MODE_REMOTE])) {
69+
throw new InvalidArgumentException('Invalid asset mode `'.$mode.'` for '.__CLASS__);
70+
}
71+
$this->mode = $mode;
72+
$this->local_asset_path = rtrim($local_asset_path, '/');
73+
74+
if ($this->mode === static::MODE_REMOTE) {
75+
$this->remote_asset_url = $this->loadAssetBaseUrl($asset_base_url_file);
76+
}
77+
}
78+
79+
protected function loadAssetBaseUrl(string $asset_base_url_file): string
80+
{
81+
if ( ! file_exists($asset_base_url_file)) {
82+
throw new InvalidArgumentException('No asset base url file at '.$asset_base_url_file);
83+
}
84+
85+
$url = require $asset_base_url_file;
86+
87+
if (empty($url) OR ! is_string($url)) {
88+
throw new RuntimeException('Invalid content in asset base url file '.$asset_base_url_file);
89+
}
90+
91+
return $url;
92+
}
93+
94+
/**
95+
* Generates a URL for a static asset, given its relative path.
96+
*
97+
* @param string $rel_path the path of the asset within the docroot / uploaded asset files
98+
*
99+
* @return string the full URL to render to the client
100+
*/
101+
public function getUrl(string $rel_path): string
102+
{
103+
if ($rel_path[0] !== '/') {
104+
$rel_path = '/'.$rel_path;
105+
}
106+
107+
if ($this->mode === static::MODE_LOCAL) {
108+
return $this->getLocalTimestampedUrl($rel_path);
109+
} else {
110+
return $this->remote_asset_url.$rel_path;
111+
}
112+
}
113+
114+
/**
115+
* @param string $rel_path
116+
*
117+
* @return string
118+
*/
119+
protected function getLocalTimestampedUrl(string $rel_path): string
120+
{
121+
$local_path = $this->local_asset_path.$rel_path;
122+
if ( ! file_exists($local_path)) {
123+
throw new RuntimeException('Undefined asset file '.$local_path);
124+
}
125+
126+
return $rel_path.'?v='.filemtime($local_path);
127+
}
128+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<?php
2+
3+
4+
namespace test\unit\Ingenerator\PHPUtils\Assets;
5+
6+
7+
use org\bovigo\vfs\vfsStream;
8+
use org\bovigo\vfs\vfsStreamDirectory;
9+
use PHPUnit\Framework\TestCase;
10+
use Ingenerator\PHPUtils\Assets\StaticAssetUrlProvider;
11+
12+
class StaticAssetUrlProviderTest extends TestCase
13+
{
14+
15+
protected $options = [
16+
'mode' => StaticAssetUrlProvider::MODE_LOCAL,
17+
'local_asset_path' => __DIR__,
18+
'asset_base_url_file' => __FILE__,
19+
];
20+
21+
/**
22+
* @var vfsStreamDirectory
23+
*/
24+
protected $vfs;
25+
26+
public function test_it_is_initialisable()
27+
{
28+
$this->assertInstanceOf(StaticAssetUrlProvider::class, $this->newSubject());
29+
}
30+
31+
public function test_it_throws_in_invalid_mode()
32+
{
33+
$this->options['mode'] = 'some-junk';
34+
$this->expectException(\InvalidArgumentException::class);
35+
$this->newSubject();
36+
}
37+
38+
public function test_in_local_mode_get_url_throws_if_file_does_not_exist()
39+
{
40+
$subject = $this->newSubject();
41+
$this->expectException(\RuntimeException::class);
42+
$subject->getUrl('assets/some-file.css');
43+
}
44+
45+
/**
46+
* @testWith ["assets/my-file.css"]
47+
* ["/assets/my-file.css"]
48+
*/
49+
public function test_in_local_mode_get_url_returns_absolute_url_with_mtime_suffix($rel_path)
50+
{
51+
vfsStream::create(
52+
[
53+
'assets' => [
54+
'my-file.css' => 'some-content',
55+
],
56+
],
57+
$this->vfs->getChild('docroot')
58+
);
59+
$this->vfs->getChild('docroot/assets/my-file.css')->lastModified(123456);
60+
$this->assertSame(
61+
'/assets/my-file.css?v=123456',
62+
$this->newSubject()->getUrl($rel_path)
63+
);
64+
}
65+
66+
public function test_in_remote_mode_get_url_throws_if_asset_base_url_file_does_not_exist()
67+
{
68+
$this->options['asset_base_url_file'] = $this->vfs->url().'/no-such-file.php';
69+
$this->options['mode'] = StaticAssetUrlProvider::MODE_REMOTE;
70+
$this->expectException(\InvalidArgumentException::class);
71+
$this->expectExceptionMessage('no-such-file.php');
72+
$this->newSubject();
73+
}
74+
75+
/**
76+
* @testWith [""]
77+
* ["some content that is not php"]
78+
* ["<?php $a = 1;"]
79+
* ["<?php return '';"]
80+
*/
81+
public function test_in_remote_mode_get_url_throws_if_asset_base_url_file_does_not_return_string($file_content)
82+
{
83+
vfsStream::create(
84+
['asset-base-url.php' => $file_content],
85+
$this->vfs
86+
);
87+
88+
$this->options['asset_base_url_file'] = $this->vfs->getChild('asset-base-url.php')->url();
89+
$this->options['mode'] = StaticAssetUrlProvider::MODE_REMOTE;
90+
$this->expectException(\RuntimeException::class);
91+
$this->expectExceptionMessage('Invalid content in asset base url');
92+
$this->newSubject();
93+
}
94+
95+
/**
96+
* @testWith ["assets/my-file.css"]
97+
* ["/assets/my-file.css"]
98+
*/
99+
public function test_it_remote_mode_get_url_returns_url_prefixed_with_base_url($rel_path)
100+
{
101+
vfsStream::create(
102+
['asset-base-url.php' => '<?php return "https://i.am.the.walrus/branch/sha";'],
103+
$this->vfs
104+
);
105+
106+
$this->options['asset_base_url_file'] = $this->vfs->getChild('asset-base-url.php')->url();
107+
$this->options['mode'] = StaticAssetUrlProvider::MODE_REMOTE;
108+
109+
$this->assertSame(
110+
'https://i.am.the.walrus/branch/sha/assets/my-file.css',
111+
$this->newSubject()->getUrl($rel_path)
112+
);
113+
}
114+
115+
protected function setUp()
116+
{
117+
parent::setUp();
118+
$this->vfs = vfsStream::setup('vfs', NULL, ['docroot' => []]);
119+
$this->options['local_asset_path'] = $this->vfs->getChild('docroot')->url();
120+
$this->options['asset_base_url_file'] = $this->vfs->url().'/asset-base-url.php';
121+
}
122+
123+
protected function newSubject()
124+
{
125+
return new StaticAssetUrlProvider(
126+
$this->options['mode'],
127+
$this->options['local_asset_path'],
128+
$this->options['asset_base_url_file']
129+
);
130+
}
131+
132+
}

test/unit/DeploymentConfig/ConfigValueDecrypterTest.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,11 @@ public function test_it_throws_if_required_decryption_key_is_not_present()
145145
$valid_encrypted_val = sodium_crypto_box_seal('whoops', sodium_crypto_box_publickey($kp));
146146
// The actual encrypted value is fine, but the problem is that the keypair it specifies isn't present with the
147147
// correct name.
148-
$subject = $this->newSubject();
148+
$other_keypair_name = uniqid('very');
149+
$subject = $this->newSubject();
149150
$this->expectException(InvalidConfigException::class);
150151
$this->expectExceptionMessage('Unknown config decryption key');
151-
$subject->decrypt('#SECRET-very#'.$valid_encrypted_val);
152+
$subject->decrypt("#SECRET-$other_keypair_name#$valid_encrypted_val");
152153
}
153154

154155
public function provider_failed_decrypt()

0 commit comments

Comments
 (0)