Add Guide, tweak class

This commit is contained in:
MGatner 2021-05-27 20:15:37 +00:00 committed by John Paul E. Balandan, CPA
parent c18a08fae8
commit ea6ca9fec0
5 changed files with 598 additions and 32 deletions

View File

@ -15,6 +15,7 @@ use CodeIgniter\Autoloader\FileLocator;
use CodeIgniter\Files\File;
use CodeIgniter\HTTP\URI;
use CodeIgniter\Publisher\Exceptions\PublisherException;
use RuntimeException;
use Throwable;
/**
@ -64,6 +65,13 @@ class Publisher
*/
private $errors = [];
/**
* List of file published curing the last write operation.
*
* @var string[]
*/
private $published = [];
/**
* Base path to use for the source.
*
@ -85,7 +93,7 @@ class Publisher
*
* @return self[]
*/
public static function discover(string $directory = 'Publishers'): array
final public static function discover(string $directory = 'Publishers'): array
{
if (isset(self::$discovered[$directory]))
{
@ -301,7 +309,7 @@ class Publisher
}
/**
* Reads files in the sources and copies them out to their destinations.
* Reads files from the sources and copies them out to their destinations.
* This method should be reimplemented by child classes intended for
* discovery.
*
@ -309,17 +317,42 @@ class Publisher
*/
public function publish(): bool
{
if ($this->source === ROOTPATH && $this->destination === FCPATH)
{
throw new RuntimeException('Child classes of Publisher should provide their own source and destination or publish method.');
}
return $this->addPath('/')->merge(true);
}
//--------------------------------------------------------------------
/**
* Returns the source directory.
*
* @return string
*/
final public function getSource(): string
{
return $this->source;
}
/**
* Returns the destination directory.
*
* @return string
*/
final public function getDestination(): string
{
return $this->destination;
}
/**
* Returns the temporary workspace, creating it if necessary.
*
* @return string
*/
protected function getScratch(): string
final public function getScratch(): string
{
if (is_null($this->scratch))
{
@ -335,17 +368,27 @@ class Publisher
*
* @return array<string,Throwable>
*/
public function getErrors(): array
final public function getErrors(): array
{
return $this->errors;
}
/**
* Returns the files published by the last write operation.
*
* @return string[]
*/
final public function getPublished(): array
{
return $this->published;
}
/**
* Optimizes and returns the current file list.
*
* @return string[]
*/
public function getFiles(): array
final public function getFiles(): array
{
$this->files = array_unique($this->files, SORT_STRING);
sort($this->files, SORT_STRING);
@ -353,6 +396,8 @@ class Publisher
return $this->files;
}
//--------------------------------------------------------------------
/**
* Sets the file list directly, files are still subject to verification.
* This works as a "reset" method with [].
@ -361,15 +406,13 @@ class Publisher
*
* @return $this
*/
public function setFiles(array $files)
final public function setFiles(array $files)
{
$this->files = [];
return $this->addFiles($files);
}
//--------------------------------------------------------------------
/**
* Verifies and adds files to the list.
*
@ -377,7 +420,7 @@ class Publisher
*
* @return $this
*/
public function addFiles(array $files)
final public function addFiles(array $files)
{
foreach ($files as $file)
{
@ -394,7 +437,7 @@ class Publisher
*
* @return $this
*/
public function addFile(string $file)
final public function addFile(string $file)
{
$this->files[] = self::resolveFile($file);
@ -408,7 +451,7 @@ class Publisher
*
* @return $this
*/
public function removeFiles(array $files)
final public function removeFiles(array $files)
{
$this->files = array_diff($this->files, $files);
@ -422,7 +465,7 @@ class Publisher
*
* @return $this
*/
public function removeFile(string $file)
final public function removeFile(string $file)
{
return $this->removeFiles([$file]);
}
@ -438,7 +481,7 @@ class Publisher
*
* @return $this
*/
public function addDirectories(array $directories, bool $recursive = false)
final public function addDirectories(array $directories, bool $recursive = false)
{
foreach ($directories as $directory)
{
@ -456,7 +499,7 @@ class Publisher
*
* @return $this
*/
public function addDirectory(string $directory, bool $recursive = false)
final public function addDirectory(string $directory, bool $recursive = false)
{
$directory = self::resolveDirectory($directory);
@ -486,7 +529,7 @@ class Publisher
*
* @return $this
*/
public function addPaths(array $paths, bool $recursive = true)
final public function addPaths(array $paths, bool $recursive = true)
{
foreach ($paths as $path)
{
@ -504,7 +547,7 @@ class Publisher
*
* @return $this
*/
public function addPath(string $path, bool $recursive = true)
final public function addPath(string $path, bool $recursive = true)
{
$full = $this->source . $path;
@ -530,7 +573,7 @@ class Publisher
*
* @return $this
*/
public function addUris(array $uris)
final public function addUris(array $uris)
{
foreach ($uris as $uri)
{
@ -547,7 +590,7 @@ class Publisher
*
* @return $this
*/
public function addUri(string $uri)
final public function addUri(string $uri)
{
// Figure out a good filename (using URI strips queries and fragments)
$file = $this->getScratch() . basename((new URI($uri))->getPath());
@ -569,7 +612,7 @@ class Publisher
*
* @return $this
*/
public function removePattern(string $pattern, string $scope = null)
final public function removePattern(string $pattern, string $scope = null)
{
if ($pattern === '')
{
@ -592,7 +635,7 @@ class Publisher
*
* @return $this
*/
public function retainPattern(string $pattern, string $scope = null)
final public function retainPattern(string $pattern, string $scope = null)
{
if ($pattern === '')
{
@ -613,7 +656,7 @@ class Publisher
*
* @return $this
*/
public function wipe()
final public function wipe()
{
self::wipeDirectory($this->destination);
@ -629,9 +672,9 @@ class Publisher
*
* @return boolean Whether all files were copied successfully
*/
public function copy(bool $replace = true): bool
final public function copy(bool $replace = true): bool
{
$this->errors = [];
$this->errors = $this->published = [];
foreach ($this->getFiles() as $file)
{
@ -640,6 +683,7 @@ class Publisher
try
{
self::safeCopyFile($file, $to, $replace);
$this->published[] = $to;
}
catch (Throwable $e)
{
@ -658,9 +702,9 @@ class Publisher
*
* @return boolean Whether all files were copied successfully
*/
public function merge(bool $replace = true): bool
final public function merge(bool $replace = true): bool
{
$this->errors = [];
$this->errors = $this->published = [];
// Get the file from source for special handling
$sourced = self::filterFiles($this->getFiles(), $this->source);
@ -678,6 +722,7 @@ class Publisher
try
{
self::safeCopyFile($file, $to, $replace);
$this->published[] = $to;
}
catch (Throwable $e)
{

View File

@ -4,13 +4,62 @@ namespace Tests\Support\Publishers;
use CodeIgniter\Publisher\Publisher;
class TestPublisher extends Publisher
final class TestPublisher extends Publisher
{
/**
* Runs the defined Operations.
* Fakes an error on the given file.
*
* @return $this
*/
public static function setError(string $file)
{
self::$error = $file;
}
/**
* A file to cause an error
*
* @var string
*/
private static $error = '';
/**
* Base path to use for the source.
*
* @var string
*/
protected $source = SUPPORTPATH . 'Files';
/**
* Base path to use for the destination.
*
* @var string
*/
protected $destination = WRITEPATH;
/**
* Fakes a publish event so no files are actually copied.
*/
public function publish(): bool
{
$this->downloadFromUrls($urls)->mergeToDirectory(FCPATH . 'assets');
$this->errors = $this->published = [];
$this->addPath('');
// Copy each sourced file to its relative destination
foreach ($this->getFiles() as $file)
{
if ($file === self::$error)
{
$this->errors[$file] = new RuntimeException('Have an error, dear.');
}
else
{
// Resolve the destination path
$this->published[] = $this->destination . substr($file, strlen($this->source));
}
}
return $this->errors === [];
}
}

View File

@ -112,6 +112,7 @@ class PublisherOutputTest extends CIUnitTestCase
$result = $publisher->copy(true);
$this->assertTrue($result);
$this->assertSame([$this->root->url() . '/banana.php'], $publisher->getPublished());
}
public function testCopyIgnoresCollision()
@ -121,10 +122,10 @@ class PublisherOutputTest extends CIUnitTestCase
mkdir($this->root->url() . '/banana.php');
$result = $publisher->addFile($this->file)->copy(false);
$errors = $publisher->getErrors();
$this->assertTrue($result);
$this->assertSame([], $errors);
$this->assertSame([], $publisher->getErrors());
$this->assertSame([$this->root->url() . '/banana.php'], $publisher->getPublished());
}
public function testCopyCollides()
@ -140,6 +141,7 @@ class PublisherOutputTest extends CIUnitTestCase
$this->assertFalse($result);
$this->assertCount(1, $errors);
$this->assertSame([$this->file], array_keys($errors));
$this->assertSame([], $publisher->getPublished());
$this->assertSame($expected, $errors[$this->file]->getMessage());
}
@ -148,6 +150,12 @@ class PublisherOutputTest extends CIUnitTestCase
public function testMerge()
{
$publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url());
$expected = [
$this->root->url() . '/able/apple.php',
$this->root->url() . '/able/fig_3.php',
$this->root->url() . '/able/prune_ripe.php',
$this->root->url() . '/baker/banana.php',
];
$this->assertFileDoesNotExist($this->root->url() . '/able/fig_3.php');
$this->assertDirectoryDoesNotExist($this->root->url() . '/baker');
@ -157,23 +165,36 @@ class PublisherOutputTest extends CIUnitTestCase
$this->assertTrue($result);
$this->assertFileExists($this->root->url() . '/able/fig_3.php');
$this->assertDirectoryExists($this->root->url() . '/baker');
$this->assertSame($expected, $publisher->getPublished());
}
public function testMergeReplace()
{
$this->assertFalse(same_file($this->directory . 'apple.php', $this->root->url() . '/able/apple.php'));
$publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url());
$expected = [
$this->root->url() . '/able/apple.php',
$this->root->url() . '/able/fig_3.php',
$this->root->url() . '/able/prune_ripe.php',
$this->root->url() . '/baker/banana.php',
];
$result = $publisher->addPath('/')->merge(true);
$this->assertTrue($result);
$this->assertTrue(same_file($this->directory . 'apple.php', $this->root->url() . '/able/apple.php'));
$this->assertSame($expected, $publisher->getPublished());
}
public function testMergeCollides()
{
$publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url());
$expected = lang('Publisher.collision', ['dir', $this->directory . 'fig_3.php', $this->root->url() . '/able/fig_3.php']);
$published = [
$this->root->url() . '/able/apple.php',
$this->root->url() . '/able/prune_ripe.php',
$this->root->url() . '/baker/banana.php',
];
mkdir($this->root->url() . '/able/fig_3.php');
@ -183,6 +204,7 @@ class PublisherOutputTest extends CIUnitTestCase
$this->assertFalse($result);
$this->assertCount(1, $errors);
$this->assertSame([$this->directory . 'fig_3.php'], array_keys($errors));
$this->assertSame($published, $publisher->getPublished());
$this->assertSame($expected, $errors[$this->directory . 'fig_3.php']->getMessage());
}

View File

@ -126,13 +126,26 @@ class PublisherSupportTest extends CIUnitTestCase
//--------------------------------------------------------------------
public function testGetSource()
{
$publisher = new Publisher(ROOTPATH);
$this->assertSame(ROOTPATH, $publisher->getSource());
}
public function testGetDestination()
{
$publisher = new Publisher(ROOTPATH, SUPPORTPATH);
$this->assertSame(SUPPORTPATH, $publisher->getDestination());
}
public function testGetScratch()
{
$publisher = new Publisher();
$this->assertNull($this->getPrivateProperty($publisher, 'scratch'));
$method = $this->getPrivateMethodInvoker($publisher, 'getScratch');
$scratch = $method();
$scratch = $publisher->getScratch();
$this->assertIsString($scratch);
$this->assertDirectoryExists($scratch);

View File

@ -0,0 +1,437 @@
#########
Publisher
#########
The Publisher library provides a means to copy files within a project using robust detection and error checking.
.. contents::
:local:
:depth: 2
*******************
Loading the Library
*******************
Because Publisher instances are specific to their source and destination this library is not available
through ``Services`` but should be instantiated or extended directly. E.g.
$publisher = new \CodeIgniter\Publisher\Publisher();
*****************
Concept and Usage
*****************
``Publisher`` solves a handful of common problems when working within a backend framework:
* How do I maintain project assets with version dependencies?
* How do I manage uploads and other "dynamic" files that need to be web accessible?
* How can I update my project when the framework or modules change?
* How can components inject new content into existing projects?
At its most basic, publishing amounts to copying a file or files into a project. ``Publisher`` uses fluent-style
command chaining to read, filter, and process input files, then copies or merges them into the target destination.
You may use ``Publisher`` on demand in your Controllers or other components, or you may stage publications by extending
the class and leveraging its discovery with ``spark publish``.
On Demand
=========
Access ``Publisher`` directly by instantiating a new instance of the class::
$publisher = new \CodeIgniter\Publisher\Publisher();
By default the source and destination will be set to ``ROOTPATH`` and ``FCPATH`` respectively, giving ``Publisher``
easy access to take any file from your project and make it web-accessible. Alternatively you may pass a new source
or source and destination into the constructor::
$vendorPublisher = new Publisher(ROOTPATH . 'vendor');
$filterPublisher = new Publisher('/path/to/module/Filters', APPPATH . 'Filters');
Once the source and destination are set you may start adding relative input files::
$frameworkPublisher = new Publisher(ROOTPATH . 'vendor/codeigniter4/codeigniter4');
// All "path" commands are relative to $source
$frameworkPublisher->addPath('app/Config/Cookie.php');
// You may also add from outside the source, but the files will not be merged into subdirectories
$frameworkPublisher->addFiles([
'/opt/mail/susan',
'/opt/mail/ubuntu',
]);
$frameworkPublisher->addDirectory(SUPPORTPATH . 'Images');
Once all the files are staged use one of the output commands (**copy()** or **merge()**) to process the staged files
to their destination(s)::
// Place all files into $destination
$frameworkPublisher->copy();
// Place all files into $destination, overwriting existing files
$frameworkPublisher->copy(true);
// Place files into their relative $destination directories, overwriting and saving the boolean result
$result = $frameworkPublisher->merge(true);
See the Library Reference for a full description of available methods.
Automation and Discovery
========================
You may have regular publication tasks embedded as part of your application deployment or upkeep. ``Publisher`` leverages
the powerful ``Autoloader`` to locate any child classes primed for publication::
use CodeIgniter\CLI\CLI;
use CodeIgniter\Publisher\Publisher;
foreach (Publisher::discover() as $publisher)
{
$result = $publisher->publish();
if ($result === false)
{
CLI::write(get_class($publisher) . ' failed to publish!', 'red');
}
}
By default ``discover()`` will search for the "Publishers" directory across all namespaces, but you may specify a
different directory and it will return any child classes found::
$memePublishers = Publisher::discover('CatGIFs');
Most of the time you will not need to handle your own discovery, just use the provided "publish" command::
> php spark publish
By default on your class extension ``publish()`` will add all files from your ``$source`` and merge them
out to your destination, overwriting on collision.
********
Examples
********
Here are a handful of example use cases and their implementations to help you get started publishing.
File Sync Example
=================
You want to display a "photo of the day" image on your homepage. You have a feed for daily photos but you
need to get the actual file into a browsable location in your project at **public/images/daily_photo.jpg**.
You can set up :doc:`Custom Command </cli/cli_commands>` to run daily that will handle this for you::
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\Publisher\Publisher;
use Throwable;
class DailyPhoto extends BaseCommand
{
protected $group = 'Publication';
protected $name = 'publish:daily';
protected $description = 'Publishes the latest daily photo to the homepage.';
public function run(array $params)
{
$publisher = new Publisher('/path/to/photos/', FCPATH . 'assets/images');
try
{
$publisher->addPath('daily_photo.jpg')->copy($replace = true);
}
catch (Throwable $e)
{
$this->showError($e);
}
}
}
Now running ``spark publish:daily`` will keep your homepage's image up-to-date. What if the photo is
coming from an external API? You can use ``addUri()`` in place of ``addPath()`` to download the remote
resource and publish it out instead::
$publisher->addUri('https://example.com/feeds/daily_photo.jpg')->copy($replace = true);
Asset Dependencies Example
==========================
You want to integrate the frontend library "Bootstrap" into your project, but the frequent updates makes it a hassle
to keep up with. You can create a publication definition in your project to sync frontend assets by adding extending
``Publisher`` in your project. So **app/Publishers/BootstrapPublisher.php** might look like this::
namespace App\Publishers;
use CodeIgniter\Publisher\Publisher;
class BootstrapPublisher extends Publisher
{
/**
* Tell Publisher where to get the files.
* Since we will use Composer to download
* them we point to the "vendor" directory.
*
* @var string
*/
protected $source = 'vendor/twbs/bootstrap/';
/**
* FCPATH is always the default destination,
* but we may want them to go in a sub-folder
* to keep things organized.
*
* @var string
*/
protected $destination = FCPATH . 'bootstrap';
/**
* Use the "publish" method to indicate that this
* class is ready to be discovered and automated.
*
* @return boolean
*/
public function publish(): bool
{
return $this
// Add all the files relative to $source
->addPath('dist')
// Indicate we only want the minimized versions
->retainPattern('*.min.*)
// Merge-and-replace to retain the original directory structure
->merge(true);
}
Now add the dependency via Composer and call ``spark publish`` to run the publication::
> composer require twbs/bootstrap
> php spark publish
... and you'll end up with something like this:
public/.htaccess
public/favicon.ico
public/index.php
public/robots.txt
public/
bootstrap/
css/
bootstrap.min.css
bootstrap-utilities.min.css.map
bootstrap-grid.min.css
bootstrap.rtl.min.css
bootstrap.min.css.map
bootstrap-reboot.min.css
bootstrap-utilities.min.css
bootstrap-reboot.rtl.min.css
bootstrap-grid.min.css.map
js/
bootstrap.esm.min.js
bootstrap.bundle.min.js.map
bootstrap.bundle.min.js
bootstrap.min.js
bootstrap.esm.min.js.map
bootstrap.min.js.map
Module Deployment Example
=========================
You want to allow developers using your popular authentication module the ability to expand on the default behavior
of your Migration, Controller, and Model. You can create your own module "publish" command to inject these components
into an application for use::
namespace Math\Auth\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\Publisher\Publisher;
use Throwable;
class Publish extends BaseCommand
{
protected $group = 'Auth';
protected $name = 'auth:publish';
protected $description = 'Publish Auth components into the current application.';
public function run(array $params)
{
// Use the Autoloader to figure out the module path
$source = service('autoloader')->getNamespace('Math\\Auth');
$publisher = new Publisher($source, APPATH);
try
{
// Add only the desired components
$publisher->addPaths([
'Controllers',
'Database/Migrations',
'Models',
])->merge(false); // Be careful not to overwrite anything
}
catch (Throwable $e)
{
$this->showError($e);
return;
}
// If publication succeeded then update namespaces
foreach ($publisher->getFiles as $original)
{
// Get the location of the new file
$file = str_replace($source, APPPATH, $original);
// Replace the namespace
$contents = file_get_contents($file);
$contents = str_replace('namespace Math\\Auth', 'namespace ' . APP_NAMESPACE, );
file_put_contents($file, $contents);
}
}
}
Now when your module users run ``php spark auth:publish`` they will have the following added to their project::
app/Controllers/AuthController.php
app/Database/Migrations/2017-11-20-223112_create_auth_tables.php.php
app/Models/LoginModel.php
app/Models/UserModel.php
*****************
Library Reference
*****************
Support Methods
===============
**[static] discover(string $directory = 'Publishers'): Publisher[]**
Discovers and returns all Publishers in the specified namespace directory. For example, if both
**app/Publishers/FrameworkPublisher.php** and **myModule/src/Publishers/AssetPublisher.php** exist and are
extensions of ``Publisher`` then ``Publisher::discover()`` would return an instance of each.
**publish(): bool**
Processes the full input-process-output chain. By default this is the equivalent of calling ``addPath($source)``
and ``merge(true)`` but child classes will typically provide their own implementation. ``publish()`` is called
on all discovered Publishers when running ``spark publish``.
Returns success or failure.
**getScratch(): string**
Returns the temporary workspace, creating it if necessary. Some operations use intermediate storage to stage
files and changes, and this provides the path to a transient, writable directory that you may use as well.
**getErrors(): array<string,Throwable>**
Returns any errors from the last write operation. The array keys are the files that caused the error, and the
values are the Throwable that was caught. Use ``getMessage()`` on the Throwable to get the error message.
**getFiles(): string[]**
Returns an array of all the loaded input files.
Inputting Files
===============
**setFiles(array $files)**
Sets the list of input files to the provided string array of file paths.
**addFile(string $file)**
**addFiles(array $files)**
Adds the file or files to the current list of input files. Files are absolute paths to actual files.
**removeFile(string $file)**
**removeFiles(array $files)**
Removes the file or files from the current list of input files.
**addDirectory(string $directory, bool $recursive = false)**
**addDirectories(array $directories, bool $recursive = false)**
Adds all files from the directory or directories, optionally recursing into sub-directories. Directories are
absolute paths to actual directories.
**addPath(string $path, bool $recursive = true)**
**addPaths(array $path, bool $recursive = true)**
Adds all files indicated by the relative paths. Paths are references to actual files or directories relative
to ``$source``. If the relative path resolves to a directory then ``$recursive`` will include sub-directories.
**addUri(string $uri)**
**addUris(array $uris)**
Downloads the contents of a URI using ``CURLRequest`` into the scratch workspace then adds the resulting
file to the list.
.. note:: The CURL request made is a simple ``GET`` and uses the response body for the file contents. Some
remote files may need a custom request to be handled properly.
Filtering Files
===============
**removePattern(string $pattern, string $scope = null)**
**retainPattern(string $pattern, string $scope = null)**
Filters the current file list through the pattern (and optional scope), removing or retaining matched
files. ``$pattern`` may be a complete regex (like ``'#[A-Za-z]+\.php#'``) or a pseudo-regex similar
to ``glob()`` (like ``*.css``).
If a ``$scope`` is provided then only files in or under that directory will be considered (i.e. files
outside of ``$scope`` are always retained). When no scope is provided then all files are subject.
Examples::
$publisher = new Publisher(APPPATH . 'Config');
$publisher->addPath('/', true); // Adds all Config files and directories
$publisher->removePattern('*tion.php'); // Would remove Encryption.php, Validation.php, and boot/production.php
$publisher->removePattern('*tion.php', APPPATH . 'Config/boot'); // Would only remove boot/production.php
$publisher->retainPattern('#A.+php$#'); // Would keep only Autoload.php
$publisher->retainPattern('#d.+php$#', APPPATH . 'Config/boot'); // Would keep everything but boot/production.php and boot/testing.php
Outputting Files
================
**wipe()**
Removes all files, directories, and sub-directories from ``$destination``.
.. important:: Use wisely.
**copy(bool $replace = true): bool**
Copies all files into the ``$destination``. This does not recreate the directory structure, so every file
from the current list will end up in the same destination directory. Using ``$replace`` will cause files
to overwrite when there is already an existing file. Returns success or failure, use ``getPublished()``
and ``getErrors()`` to troubleshoot failures.
Be mindful of duplicate basename collisions, for example::
$publisher = new Publisher('/home/source', '/home/destination');
$publisher->addPaths([
'pencil/lead.png',
'metal/lead.png',
]);
// This is bad! Only one file will remain at /home/destination/lead.png
$publisher->copy(true);
**merge(bool $replace = true): bool**
Copies all files into the ``$destination`` in appropriate relative sub-directories. Any files that
match ``$source`` will be placed into their equivalent directories in ``$destination``, effectively
creating a "mirror" or "rsync" operation. Using ``$replace`` will cause files
to overwrite when there is already an existing file; since directories are merged this will not
affect other files in the destination. Returns success or failure, use ``getPublished()`` and
``getErrors()`` to troubleshoot failures.
Example::
$publisher = new Publisher('/home/source', '/home/destination');
$publisher->addPaths([
'pencil/lead.png',
'metal/lead.png',
]);
// Results in "/home/destination/pencil/lead.png" and "/home/destination/metal/lead.png"
$publisher->merge();