Skip to content

Commit 94160c1

Browse files
committed
PHPLIB-1206 Add default context resolver for GridFS StreamWrapper
1 parent be1e9e9 commit 94160c1

File tree

5 files changed

+236
-27
lines changed

5 files changed

+236
-27
lines changed

src/GridFS/Bucket.php

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,15 @@
5454
use function MongoDB\BSON\toJSON;
5555
use function property_exists;
5656
use function sprintf;
57+
use function str_contains;
58+
use function str_starts_with;
5759
use function stream_context_create;
5860
use function stream_copy_to_stream;
5961
use function stream_get_meta_data;
6062
use function stream_get_wrappers;
63+
use function strlen;
64+
use function substr;
65+
use function urldecode;
6166
use function urlencode;
6267

6368
/**
@@ -80,6 +85,8 @@ class Bucket
8085

8186
private ?DocumentCodec $codec = null;
8287

88+
private string $protocol;
89+
8390
private CollectionWrapper $collectionWrapper;
8491

8592
private string $databaseName;
@@ -130,11 +137,16 @@ class Bucket
130137
public function __construct(Manager $manager, string $databaseName, array $options = [])
131138
{
132139
$options += [
140+
'protocol' => self::STREAM_WRAPPER_PROTOCOL,
133141
'bucketName' => self::DEFAULT_BUCKET_NAME,
134142
'chunkSizeBytes' => self::DEFAULT_CHUNK_SIZE_BYTES,
135143
'disableMD5' => false,
136144
];
137145

146+
if (! is_string($options['protocol'])) {
147+
throw InvalidArgumentException::invalidType('"protocol" option', $options['protocol'], 'string');
148+
}
149+
138150
if (! is_string($options['bucketName'])) {
139151
throw InvalidArgumentException::invalidType('"bucketName" option', $options['bucketName'], 'string');
140152
}
@@ -177,6 +189,7 @@ public function __construct(Manager $manager, string $databaseName, array $optio
177189

178190
$this->manager = $manager;
179191
$this->databaseName = $databaseName;
192+
$this->protocol = $options['protocol'];
180193
$this->bucketName = $options['bucketName'];
181194
$this->chunkSizeBytes = $options['chunkSizeBytes'];
182195
$this->codec = $options['codec'] ?? null;
@@ -577,7 +590,7 @@ public function openUploadStream(string $filename, array $options = [])
577590

578591
$path = $this->createPathForUpload();
579592
$context = stream_context_create([
580-
self::STREAM_WRAPPER_PROTOCOL => [
593+
$this->protocol => [
581594
'collectionWrapper' => $this->collectionWrapper,
582595
'filename' => $filename,
583596
'options' => $options,
@@ -659,6 +672,60 @@ public function uploadFromStream(string $filename, $source, array $options = [])
659672
return $this->getFileIdForStream($destination);
660673
}
661674

675+
public function createPathForFilename(string $filename): string
676+
{
677+
return $this->createPathForFile((object) ['_id' => $filename]);
678+
}
679+
680+
/**
681+
* Create a stream context from
682+
*
683+
* @see StreamWrapper::setDefaultContextResolver()
684+
* @see stream_context_create()
685+
*
686+
* @param string $path The full url provided to fopen(). It contains the filename.
687+
* gridfs://database_name/collection_name.files/file_name
688+
*
689+
* @return array{collectionWrapper: CollectionWrapper, file: object}|array{collectionWrapper: CollectionWrapper, filename: string, options: array}|null
690+
*/
691+
public function resolveStreamContext(string $path, string $mode): ?array
692+
{
693+
// The file can be read only if it belongs to this bucket
694+
$basePath = $this->createPathForFile((object) ['_id' => '']);
695+
if (! str_starts_with($path, $basePath)) {
696+
return null;
697+
}
698+
699+
$filename = urldecode(substr($path, strlen($basePath)));
700+
701+
if (str_contains($mode, 'r')) {
702+
$file = $this->collectionWrapper->findFileByFilenameAndRevision($filename, -1);
703+
704+
// File not found
705+
if ($file === null) {
706+
return null;
707+
}
708+
709+
return [
710+
'collectionWrapper' => $this->collectionWrapper,
711+
'file' => $file,
712+
];
713+
}
714+
715+
if (str_contains($mode, 'w')) {
716+
return [
717+
'collectionWrapper' => $this->collectionWrapper,
718+
'filename' => $filename,
719+
'options' => [
720+
'chunkSizeBytes' => $this->chunkSizeBytes,
721+
'disableMD5' => $this->disableMD5,
722+
],
723+
];
724+
}
725+
726+
return null;
727+
}
728+
662729
/**
663730
* Creates a path for an existing GridFS file.
664731
*
@@ -674,7 +741,7 @@ private function createPathForFile(object $file): string
674741

675742
return sprintf(
676743
'%s://%s/%s.files/%s',
677-
self::STREAM_WRAPPER_PROTOCOL,
744+
$this->protocol,
678745
urlencode($this->databaseName),
679746
urlencode($this->bucketName),
680747
urlencode($id),
@@ -736,7 +803,7 @@ private function openDownloadStreamByFile(object $file)
736803
{
737804
$path = $this->createPathForFile($file);
738805
$context = stream_context_create([
739-
self::STREAM_WRAPPER_PROTOCOL => [
806+
$this->protocol => [
740807
'collectionWrapper' => $this->collectionWrapper,
741808
'file' => $file,
742809
],

src/GridFS/StreamWrapper.php

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,40 +17,68 @@
1717

1818
namespace MongoDB\GridFS;
1919

20+
use Closure;
2021
use MongoDB\BSON\UTCDateTime;
2122

2223
use function assert;
24+
use function call_user_func;
2325
use function explode;
2426
use function in_array;
27+
use function is_array;
2528
use function is_integer;
29+
use function is_object;
2630
use function is_resource;
31+
use function is_string;
32+
use function sprintf;
33+
use function str_contains;
2734
use function stream_context_get_options;
2835
use function stream_get_wrappers;
2936
use function stream_wrapper_register;
3037
use function stream_wrapper_unregister;
38+
use function trigger_error;
3139

40+
use const E_USER_WARNING;
3241
use const SEEK_CUR;
3342
use const SEEK_END;
3443
use const SEEK_SET;
3544
use const STREAM_IS_URL;
45+
use const STREAM_REPORT_ERRORS;
3646

3747
/**
3848
* Stream wrapper for reading and writing a GridFS file.
3949
*
4050
* @internal
4151
* @see Bucket::openUploadStream()
4252
* @see Bucket::openDownloadStream()
53+
* @psalm-type ContextOptions = array{collectionWrapper: CollectionWrapper, file: object}|array{collectionWrapper: CollectionWrapper, filename: string, options: array}|null
4354
*/
4455
class StreamWrapper
4556
{
4657
/** @var resource|null Stream context (set by PHP) */
4758
public $context;
4859

49-
private ?string $protocol = null;
50-
5160
/** @var ReadableStream|WritableStream|null */
5261
private $stream;
5362

63+
/** @var Closure(string, string): ContextOptions|null */
64+
private static ?Closure $contextResolver = null;
65+
66+
/**
67+
* In order to use the stream wrapper with file names only,...
68+
*
69+
* @see Bucket::resolveStreamContext()
70+
*
71+
* @param Bucket|Closure(string, string):ContextOptions|null $resolver
72+
*/
73+
public static function setDefaultContextResolver($resolver): void
74+
{
75+
if ($resolver instanceof Bucket) {
76+
$resolver = Closure::fromCallable([$resolver, 'resolveStreamContext']);
77+
}
78+
79+
self::$contextResolver = $resolver;
80+
}
81+
5482
public function __destruct()
5583
{
5684
/* Ensure the stream is closed so the last chunk is written. This is
@@ -123,14 +151,44 @@ public function stream_eof(): bool
123151
*/
124152
public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
125153
{
126-
$this->initProtocol($path);
154+
$protocol = $this->parseProtocol($path);
155+
156+
assert(is_resource($this->context));
157+
$contextOptions = stream_context_get_options($this->context)[$protocol] ?? null;
127158

128-
if ($mode === 'r') {
129-
return $this->initReadableStream();
159+
if ($contextOptions === null) {
160+
if (! isset(self::$contextResolver)) {
161+
if ($options & STREAM_REPORT_ERRORS) {
162+
trigger_error(sprintf('No stream context provided for "%s" protocol. Use "%s::setDefaultContextResolver() to provide a default context."', $protocol, self::class), E_USER_WARNING);
163+
}
164+
165+
return false;
166+
}
167+
168+
$contextOptions = call_user_func(self::$contextResolver, $path, $mode);
169+
if ($contextOptions === null) {
170+
if ($options & STREAM_REPORT_ERRORS) {
171+
trigger_error(sprintf('File not found "%s" with the default GridFS resolver.', $path), E_USER_WARNING);
172+
}
173+
174+
return false;
175+
}
130176
}
131177

132-
if ($mode === 'w') {
133-
return $this->initWritableStream();
178+
assert(is_array($contextOptions));
179+
assert(isset($contextOptions['collectionWrapper']) && $contextOptions['collectionWrapper'] instanceof CollectionWrapper);
180+
181+
if (str_contains($mode, 'r')) {
182+
assert(isset($contextOptions['file']) && is_object($contextOptions['file']));
183+
184+
return $this->initReadableStream($contextOptions);
185+
}
186+
187+
if (str_contains($mode, 'w')) {
188+
assert(isset($contextOptions['filename']) && is_string($contextOptions['filename']));
189+
assert(isset($contextOptions['options']) && is_array($contextOptions['options']));
190+
191+
return $this->initWritableStream($contextOptions);
134192
}
135193

136194
return false;
@@ -279,26 +337,24 @@ private function getStatTemplate(): array
279337
*
280338
* @see StreamWrapper::stream_open()
281339
*/
282-
private function initProtocol(string $path): void
340+
private function parseProtocol(string $path): string
283341
{
284342
$parts = explode('://', $path, 2);
285-
$this->protocol = $parts[0] ?: 'gridfs';
343+
344+
return $parts[0] ?: 'gridfs';
286345
}
287346

288347
/**
289348
* Initialize the internal stream for reading.
290349
*
350+
* @param array{collectionWrapper: CollectionWrapper, file: object, ...} $contextOptions
291351
* @see StreamWrapper::stream_open()
292352
*/
293-
private function initReadableStream(): bool
353+
private function initReadableStream(array $contextOptions): bool
294354
{
295-
assert(is_resource($this->context));
296-
$context = stream_context_get_options($this->context);
297-
298-
assert($this->protocol !== null);
299355
$this->stream = new ReadableStream(
300-
$context[$this->protocol]['collectionWrapper'],
301-
$context[$this->protocol]['file'],
356+
$contextOptions['collectionWrapper'],
357+
$contextOptions['file'],
302358
);
303359

304360
return true;
@@ -307,18 +363,15 @@ private function initReadableStream(): bool
307363
/**
308364
* Initialize the internal stream for writing.
309365
*
366+
* @param array{collectionWrapper: CollectionWrapper, filename: string, options: array, ...} $contextOptions
310367
* @see StreamWrapper::stream_open()
311368
*/
312-
private function initWritableStream(): bool
369+
private function initWritableStream(array $contextOptions): bool
313370
{
314-
assert(is_resource($this->context));
315-
$context = stream_context_get_options($this->context);
316-
317-
assert($this->protocol !== null);
318371
$this->stream = new WritableStream(
319-
$context[$this->protocol]['collectionWrapper'],
320-
$context[$this->protocol]['filename'],
321-
$context[$this->protocol]['options'],
372+
$contextOptions['collectionWrapper'],
373+
$contextOptions['filename'],
374+
$contextOptions['options'],
322375
);
323376

324377
return true;

tests/GridFS/BucketFunctionalTest.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
namespace MongoDB\Tests\GridFS;
44

55
use MongoDB\BSON\Binary;
6+
use MongoDB\BSON\ObjectId;
67
use MongoDB\Collection;
78
use MongoDB\Driver\ReadConcern;
89
use MongoDB\Driver\ReadPreference;
910
use MongoDB\Driver\WriteConcern;
1011
use MongoDB\Exception\InvalidArgumentException;
1112
use MongoDB\GridFS\Bucket;
13+
use MongoDB\GridFS\CollectionWrapper;
1214
use MongoDB\GridFS\Exception\CorruptFileException;
1315
use MongoDB\GridFS\Exception\FileNotFoundException;
1416
use MongoDB\GridFS\Exception\StreamException;
@@ -891,6 +893,44 @@ public function testDanglingOpenWritableStream(): void
891893
$this->assertSame(14, $fileDocument->length);
892894
}
893895

896+
public function testCreatePathForFilename(): void
897+
{
898+
$filename = 'filename';
899+
$expected = sprintf('gridfs://%s/%s.files/%s', $this->bucket->getDatabaseName(), $this->bucket->getBucketName(), $filename);
900+
901+
$this->assertSame($expected, $this->bucket->createPathForFilename($filename));
902+
}
903+
904+
public function testResolveStreamContextForRead(): void
905+
{
906+
$stream = $this->bucket->openUploadStream('filename');
907+
fwrite($stream, 'foobar');
908+
fclose($stream);
909+
910+
$context = $this->bucket->resolveStreamContext($this->bucket->createPathForFilename('filename'), 'rb');
911+
912+
$this->assertIsArray($context);
913+
$this->assertArrayHasKey('collectionWrapper', $context);
914+
$this->assertInstanceOf(CollectionWrapper::class, $context['collectionWrapper']);
915+
$this->assertArrayHasKey('file', $context);
916+
$this->assertIsObject($context['file']);
917+
$this->assertInstanceOf(ObjectId::class, $context['file']->_id);
918+
$this->assertSame('filename', $context['file']->filename);
919+
}
920+
921+
public function testResolveStreamContextForWrite(): void
922+
{
923+
$context = $this->bucket->resolveStreamContext($this->bucket->createPathForFilename('filename'), 'wb');
924+
925+
$this->assertIsArray($context);
926+
$this->assertArrayHasKey('collectionWrapper', $context);
927+
$this->assertInstanceOf(CollectionWrapper::class, $context['collectionWrapper']);
928+
$this->assertArrayHasKey('filename', $context);
929+
$this->assertSame('filename', $context['filename']);
930+
$this->assertArrayHasKey('options', $context);
931+
$this->assertSame(['chunkSizeBytes' => 261120, 'disableMD5' => false], $context['options']);
932+
}
933+
894934
/**
895935
* Asserts that an index with the given name exists for the collection.
896936
*

tests/GridFS/FunctionalTestCase.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use MongoDB\Collection;
66
use MongoDB\GridFS\Bucket;
7+
use MongoDB\GridFS\StreamWrapper;
78
use MongoDB\Tests\FunctionalTestCase as BaseFunctionalTestCase;
89

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

38+
public function tearDown(): void
39+
{
40+
StreamWrapper::setDefaultContextResolver(null);
41+
42+
parent::tearDown();
43+
}
44+
3745
/**
3846
* Asserts that a variable is a stream containing the expected data.
3947
*

0 commit comments

Comments
 (0)