Skip to content

Commit

Permalink
PHPLIB-1206 Add default context resolver for GridFS StreamWrapper
Browse files Browse the repository at this point in the history
  • Loading branch information
GromNaN committed Jul 24, 2023
1 parent e3b6462 commit e982b85
Show file tree
Hide file tree
Showing 5 changed files with 236 additions and 27 deletions.
73 changes: 70 additions & 3 deletions src/GridFS/Bucket.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,15 @@
use function MongoDB\BSON\toJSON;
use function property_exists;
use function sprintf;
use function str_contains;
use function str_starts_with;
use function stream_context_create;
use function stream_copy_to_stream;
use function stream_get_meta_data;
use function stream_get_wrappers;
use function strlen;
use function substr;
use function urldecode;
use function urlencode;

/**
Expand All @@ -74,6 +79,8 @@ class Bucket

private const STREAM_WRAPPER_PROTOCOL = 'gridfs';

private string $protocol;

private CollectionWrapper $collectionWrapper;

private string $databaseName;
Expand Down Expand Up @@ -124,11 +131,16 @@ class Bucket
public function __construct(Manager $manager, string $databaseName, array $options = [])
{
$options += [
'protocol' => self::STREAM_WRAPPER_PROTOCOL,
'bucketName' => self::DEFAULT_BUCKET_NAME,
'chunkSizeBytes' => self::DEFAULT_CHUNK_SIZE_BYTES,
'disableMD5' => false,
];

if (! is_string($options['protocol'])) {
throw InvalidArgumentException::invalidType('"protocol" option', $options['protocol'], 'string');
}

if (! is_string($options['bucketName'])) {
throw InvalidArgumentException::invalidType('"bucketName" option', $options['bucketName'], 'string');
}
Expand Down Expand Up @@ -163,6 +175,7 @@ public function __construct(Manager $manager, string $databaseName, array $optio

$this->manager = $manager;
$this->databaseName = $databaseName;
$this->protocol = $options['protocol'];
$this->bucketName = $options['bucketName'];
$this->chunkSizeBytes = $options['chunkSizeBytes'];
$this->disableMD5 = $options['disableMD5'];
Expand Down Expand Up @@ -549,7 +562,7 @@ public function openUploadStream(string $filename, array $options = [])

$path = $this->createPathForUpload();
$context = stream_context_create([
self::STREAM_WRAPPER_PROTOCOL => [
$this->protocol => [
'collectionWrapper' => $this->collectionWrapper,
'filename' => $filename,
'options' => $options,
Expand Down Expand Up @@ -631,6 +644,60 @@ public function uploadFromStream(string $filename, $source, array $options = [])
return $this->getFileIdForStream($destination);
}

public function createPathForFilename(string $filename): string
{
return $this->createPathForFile((object) ['_id' => $filename]);
}

/**
* Create a stream context from
*
* @see StreamWrapper::setDefaultContextResolver()
* @see stream_context_create()
*
* @param string $path The full url provided to fopen(). It contains the filename.
* gridfs://database_name/collection_name.files/file_name
*
* @return array{collectionWrapper: CollectionWrapper, file: object}|array{collectionWrapper: CollectionWrapper, filename: string, options: array}|null
*/
public function resolveStreamContext(string $path, string $mode): ?array
{
// The file can be read only if it belongs to this bucket
$basePath = $this->createPathForFile((object) ['_id' => '']);
if (! str_starts_with($path, $basePath)) {
return null;
}

$filename = urldecode(substr($path, strlen($basePath)));

if (str_contains($mode, 'r')) {
$file = $this->collectionWrapper->findFileByFilenameAndRevision($filename, -1);

// File not found
if ($file === null) {
return null;
}

return [
'collectionWrapper' => $this->collectionWrapper,
'file' => $file,
];
}

if (str_contains($mode, 'w')) {
return [
'collectionWrapper' => $this->collectionWrapper,
'filename' => $filename,
'options' => [
'chunkSizeBytes' => $this->chunkSizeBytes,
'disableMD5' => $this->disableMD5,
],
];
}

return null;
}

/**
* Creates a path for an existing GridFS file.
*
Expand All @@ -646,7 +713,7 @@ private function createPathForFile(object $file): string

return sprintf(
'%s://%s/%s.files/%s',
self::STREAM_WRAPPER_PROTOCOL,
$this->protocol,
urlencode($this->databaseName),
urlencode($this->bucketName),
urlencode($id),
Expand Down Expand Up @@ -708,7 +775,7 @@ private function openDownloadStreamByFile(object $file)
{
$path = $this->createPathForFile($file);
$context = stream_context_create([
self::STREAM_WRAPPER_PROTOCOL => [
$this->protocol => [
'collectionWrapper' => $this->collectionWrapper,
'file' => $file,
],
Expand Down
101 changes: 77 additions & 24 deletions src/GridFS/StreamWrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,40 +17,68 @@

namespace MongoDB\GridFS;

use Closure;
use MongoDB\BSON\UTCDateTime;

use function assert;
use function call_user_func;
use function explode;
use function in_array;
use function is_array;
use function is_integer;
use function is_object;
use function is_resource;
use function is_string;
use function sprintf;
use function str_contains;
use function stream_context_get_options;
use function stream_get_wrappers;
use function stream_wrapper_register;
use function stream_wrapper_unregister;
use function trigger_error;

use const E_USER_WARNING;
use const SEEK_CUR;
use const SEEK_END;
use const SEEK_SET;
use const STREAM_IS_URL;
use const STREAM_REPORT_ERRORS;

/**
* Stream wrapper for reading and writing a GridFS file.
*
* @internal
* @see Bucket::openUploadStream()
* @see Bucket::openDownloadStream()
* @psalm-type ContextOptions = array{collectionWrapper: CollectionWrapper, file: object}|array{collectionWrapper: CollectionWrapper, filename: string, options: array}|null
*/
class StreamWrapper
{
/** @var resource|null Stream context (set by PHP) */
public $context;

private ?string $protocol = null;

/** @var ReadableStream|WritableStream|null */
private $stream;

/** @var Closure(string, string): ContextOptions|null */
private static ?Closure $contextResolver = null;

/**
* In order to use the stream wrapper with file names only,...
*
* @see Bucket::resolveStreamContext()
*
* @param Bucket|Closure(string, string):ContextOptions|null $resolver
*/
public static function setDefaultContextResolver($resolver): void
{
if ($resolver instanceof Bucket) {
$resolver = Closure::fromCallable([$resolver, 'resolveStreamContext']);
}

self::$contextResolver = $resolver;
}

public function __destruct()
{
/* This destructor is a workaround for PHP trying to use the stream well
Expand Down Expand Up @@ -122,14 +150,44 @@ public function stream_eof(): bool
*/
public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
{
$this->initProtocol($path);
$protocol = $this->parseProtocol($path);

assert(is_resource($this->context));
$contextOptions = stream_context_get_options($this->context)[$protocol] ?? null;

if ($mode === 'r') {
return $this->initReadableStream();
if ($contextOptions === null) {
if (! isset(self::$contextResolver)) {
if ($options & STREAM_REPORT_ERRORS) {
trigger_error(sprintf('No stream context provided for "%s" protocol. Use "%s::setDefaultContextResolver() to provide a default context."', $protocol, self::class), E_USER_WARNING);
}

return false;
}

$contextOptions = call_user_func(self::$contextResolver, $path, $mode);
if ($contextOptions === null) {
if ($options & STREAM_REPORT_ERRORS) {
trigger_error(sprintf('File not found "%s" with the default GridFS resolver.', $path), E_USER_WARNING);
}

return false;
}
}

if ($mode === 'w') {
return $this->initWritableStream();
assert(is_array($contextOptions));
assert(isset($contextOptions['collectionWrapper']) && $contextOptions['collectionWrapper'] instanceof CollectionWrapper);

if (str_contains($mode, 'r')) {
assert(isset($contextOptions['file']) && is_object($contextOptions['file']));

return $this->initReadableStream($contextOptions);
}

if (str_contains($mode, 'w')) {
assert(isset($contextOptions['filename']) && is_string($contextOptions['filename']));
assert(isset($contextOptions['options']) && is_array($contextOptions['options']));

return $this->initWritableStream($contextOptions);
}

return false;
Expand Down Expand Up @@ -278,26 +336,24 @@ private function getStatTemplate(): array
*
* @see StreamWrapper::stream_open()
*/
private function initProtocol(string $path): void
private function parseProtocol(string $path): string
{
$parts = explode('://', $path, 2);
$this->protocol = $parts[0] ?: 'gridfs';

return $parts[0] ?: 'gridfs';
}

/**
* Initialize the internal stream for reading.
*
* @param array{collectionWrapper: CollectionWrapper, file: object, ...} $contextOptions

Check failure on line 349 in src/GridFS/StreamWrapper.php

View workflow job for this annotation

GitHub Actions / phpcs

Missing parameter name
* @see StreamWrapper::stream_open()
*/
private function initReadableStream(): bool
private function initReadableStream(array $contextOptions): bool
{
assert(is_resource($this->context));
$context = stream_context_get_options($this->context);

assert($this->protocol !== null);
$this->stream = new ReadableStream(
$context[$this->protocol]['collectionWrapper'],
$context[$this->protocol]['file'],
$contextOptions['collectionWrapper'],
$contextOptions['file'],
);

return true;
Expand All @@ -306,18 +362,15 @@ private function initReadableStream(): bool
/**
* Initialize the internal stream for writing.
*
* @param array{collectionWrapper: CollectionWrapper, filename: string, options: array, ...} $contextOptions

Check failure on line 365 in src/GridFS/StreamWrapper.php

View workflow job for this annotation

GitHub Actions / phpcs

Missing parameter name
* @see StreamWrapper::stream_open()
*/
private function initWritableStream(): bool
private function initWritableStream(array $contextOptions): bool
{
assert(is_resource($this->context));
$context = stream_context_get_options($this->context);

assert($this->protocol !== null);
$this->stream = new WritableStream(
$context[$this->protocol]['collectionWrapper'],
$context[$this->protocol]['filename'],
$context[$this->protocol]['options'],
$contextOptions['collectionWrapper'],
$contextOptions['filename'],
$contextOptions['options'],
);

return true;
Expand Down
40 changes: 40 additions & 0 deletions tests/GridFS/BucketFunctionalTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
namespace MongoDB\Tests\GridFS;

use MongoDB\BSON\Binary;
use MongoDB\BSON\ObjectId;
use MongoDB\Collection;
use MongoDB\Driver\ReadConcern;
use MongoDB\Driver\ReadPreference;
use MongoDB\Driver\WriteConcern;
use MongoDB\Exception\InvalidArgumentException;
use MongoDB\GridFS\Bucket;
use MongoDB\GridFS\CollectionWrapper;
use MongoDB\GridFS\Exception\CorruptFileException;
use MongoDB\GridFS\Exception\FileNotFoundException;
use MongoDB\GridFS\Exception\StreamException;
Expand Down Expand Up @@ -745,6 +747,44 @@ public function testDanglingOpenWritableStream(): void
$this->assertSame('', $output);
}

public function testCreatePathForFilename(): void
{
$filename = 'filename';
$expected = sprintf('gridfs://%s/%s.files/%s', $this->bucket->getDatabaseName(), $this->bucket->getBucketName(), $filename);

$this->assertSame($expected, $this->bucket->createPathForFilename($filename));
}

public function testResolveStreamContextForRead(): void
{
$stream = $this->bucket->openUploadStream('filename');
fwrite($stream, 'foobar');
fclose($stream);

$context = $this->bucket->resolveStreamContext($this->bucket->createPathForFilename('filename'), 'rb');

$this->assertIsArray($context);
$this->assertArrayHasKey('collectionWrapper', $context);
$this->assertInstanceOf(CollectionWrapper::class, $context['collectionWrapper']);
$this->assertArrayHasKey('file', $context);
$this->assertIsObject($context['file']);
$this->assertInstanceOf(ObjectId::class, $context['file']->_id);
$this->assertSame('filename', $context['file']->filename);
}

public function testResolveStreamContextForWrite(): void
{
$context = $this->bucket->resolveStreamContext($this->bucket->createPathForFilename('filename'), 'wb');

$this->assertIsArray($context);
$this->assertArrayHasKey('collectionWrapper', $context);
$this->assertInstanceOf(CollectionWrapper::class, $context['collectionWrapper']);
$this->assertArrayHasKey('filename', $context);
$this->assertSame('filename', $context['filename']);
$this->assertArrayHasKey('options', $context);
$this->assertSame(['chunkSizeBytes' => 261120, 'disableMD5' => false], $context['options']);
}

/**
* Asserts that an index with the given name exists for the collection.
*
Expand Down
8 changes: 8 additions & 0 deletions tests/GridFS/FunctionalTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use MongoDB\Collection;
use MongoDB\GridFS\Bucket;
use MongoDB\GridFS\StreamWrapper;
use MongoDB\Tests\FunctionalTestCase as BaseFunctionalTestCase;

use function fopen;
Expand Down Expand Up @@ -34,6 +35,13 @@ public function setUp(): void
$this->filesCollection = $this->createCollection($this->getDatabaseName(), 'fs.files');
}

public function tearDown(): void
{
StreamWrapper::setDefaultContextResolver(null);

parent::tearDown();
}

/**
* Asserts that a variable is a stream containing the expected data.
*
Expand Down
Loading

0 comments on commit e982b85

Please sign in to comment.