diff --git a/Cargo.lock b/Cargo.lock index 36cdbc7d777..b84f1428902 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aes" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.7.6" @@ -676,6 +687,16 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + [[package]] name = "bzip2-sys" version = "0.1.11+1.0.8" @@ -842,6 +863,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clang-sys" version = "1.6.1" @@ -999,6 +1030,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + [[package]] name = "convert_case" version = "0.6.0" @@ -1555,6 +1592,41 @@ version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +[[package]] +name = "ext-php-rs" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7801d7d8a7b4d1435a2a067bf5a5e321641c78ed1016e2f773d9a9296c6e80f" +dependencies = [ + "anyhow", + "bindgen 0.65.1", + "bitflags 2.3.3", + "cc", + "cfg-if", + "ext-php-rs-derive", + "native-tls", + "once_cell", + "parking_lot 0.12.1", + "skeptic", + "ureq", + "zip", +] + +[[package]] +name = "ext-php-rs-derive" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88979a9b357bdd9b8ffea208133f0bd0fa7e4d4e18bed3242335ed7b27033cf2" +dependencies = [ + "anyhow", + "darling", + "ident_case", + "lazy_static", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "fail" version = "0.4.0" @@ -1587,6 +1659,16 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cda653ca797810c02f7ca4b804b40b8b95ae046eb989d356bce17919a8c25499" +[[package]] +name = "flate2" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flexstr" version = "0.9.2" @@ -2276,6 +2358,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + [[package]] name = "instant" version = "0.1.12" @@ -3469,6 +3560,14 @@ dependencies = [ "opendal", ] +[[package]] +name = "opendal-php" +version = "0.1.0" +dependencies = [ + "ext-php-rs", + "opendal", +] + [[package]] name = "opendal-python" version = "0.39.0" @@ -3869,12 +3968,35 @@ dependencies = [ "windows-targets 0.48.1", ] +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + [[package]] name = "peeking_take_while" version = "0.1.2" @@ -6112,6 +6234,20 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" +[[package]] +name = "ureq" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b11c96ac7ee530603dcdf68ed1557050f374ce55a5a07193ebf8cbc9f8927e9" +dependencies = [ + "base64 0.21.2", + "flate2", + "log", + "native-tls", + "once_cell", + "url", +] + [[package]] name = "url" version = "2.4.0" @@ -6627,3 +6763,53 @@ checksum = "70b40401a28d86ce16a330b863b86fd7dbee4d7c940587ab09ab8c019f9e3fdf" dependencies = [ "num-traits", ] + +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2", + "sha1", + "time 0.3.22", + "zstd", +] + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.8+zstd.1.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5556e6ee25d32df2586c098bbfa278803692a20d0ab9565e049480d52707ec8c" +dependencies = [ + "cc", + "libc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 2c99a9699a5..98a3389f5bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ members = [ "bindings/lua", "bindings/dotnet", "bindings/ocaml", + "bindings/php", "bin/oli", "bin/oay", diff --git a/bindings/php/Cargo.toml b/bindings/php/Cargo.toml index ec9f0a2d3ef..8cb0de74394 100644 --- a/bindings/php/Cargo.toml +++ b/bindings/php/Cargo.toml @@ -32,4 +32,4 @@ crate-type = ["cdylib"] [dependencies] ext-php-rs = "0.10.1" -opendal = {version = "0.38.1", path = "../../core" } +opendal.workspace = true diff --git a/bindings/php/composer.json b/bindings/php/composer.json index 1f815770319..2126c6ffb44 100644 --- a/bindings/php/composer.json +++ b/bindings/php/composer.json @@ -15,7 +15,7 @@ }, "config": { "allow-plugins": { - "pestphp/pest-plugin": false + "pestphp/pest-plugin": true } } } diff --git a/bindings/php/opendal-php.stubs.php b/bindings/php/opendal-php.stubs.php index c8e44df26bf..8b78b8030cf 100644 --- a/bindings/php/opendal-php.stubs.php +++ b/bindings/php/opendal-php.stubs.php @@ -20,6 +20,116 @@ // Stubs for opendal-php -namespace { - function debug(): string {} +namespace OpenDAL { + class Metadata { + public $content_disposition; + + public $content_md5; + + public $content_type; + + public $content_length; + + public $mode; + + public $etag; + + public function content_disposition(): ?string {} + + /** + * Content length of this entry. + */ + public function content_length(): int {} + + /** + * Content MD5 of this entry. + */ + public function content_md5(): ?string {} + + /** + * Content Type of this entry. + */ + public function content_type(): ?string {} + + /** + * ETag of this entry. + */ + public function etag(): ?string {} + + /** + * mode represent this entry's mode. + */ + public function mode(): \OpenDAL\EntryMode {} + } + + class EntryMode { + public $is_file; + + public $is_dir; + + public function is_dir(): int {} + + public function is_file(): int {} + } + + class Operator { + public function __construct(string $scheme_str, array $config) {} + + /** + * Write string into given path. + */ + public function write(string $path, string $content): mixed {} + + /** + * Write bytes into given path, binary safe. + */ + public function write_binary(string $path, array $content): mixed {} + + /** + * Read the whole path into bytes, binary safe. + */ + public function read(string $path): string {} + + /** + * Check if this path exists or not, return 1 if exists, 0 otherwise. + */ + public function is_exist(string $path): int {} + + /** + * Get current path's metadata **without cache** directly. + * + * # Notes + * + * Use `stat` if you: + * + * - Want detect the outside changes of path. + * - Don't want to read from cached metadata. + */ + public function stat(string $path): \OpenDAL\Metadata {} + + /** + * Delete given path. + * + * # Notes + * + * - Delete not existing error won't return errors. + */ + public function delete(string $path): mixed {} + + /** + * Create a dir at given path. + * + * # Notes + * + * To indicate that a path is a directory, it is compulsory to include + * a trailing / in the path. Failure to do so may result in + * `NotADirectory` error being returned by OpenDAL. + * + * # Behavior + * + * - Create on existing dir will succeed. + * - Create dir is always recursive, works like `mkdir -p` + */ + public function create_dir(string $path): mixed {} + } } diff --git a/bindings/php/src/lib.rs b/bindings/php/src/lib.rs index 288faeb3394..dbb85a8e6c5 100644 --- a/bindings/php/src/lib.rs +++ b/bindings/php/src/lib.rs @@ -15,14 +15,164 @@ // specific language governing permissions and limitations // under the License. +use ::opendal as od; +use ext_php_rs::binary::Binary; +use ext_php_rs::convert::FromZval; +use ext_php_rs::exception::PhpException; +use ext_php_rs::flags::DataType; use ext_php_rs::prelude::*; -use opendal::{EntryMode, Metadata}; +use ext_php_rs::types::Zval; +use std::collections::HashMap; +use std::str::FromStr; -#[php_function] -pub fn debug() -> String { - let metadata = Metadata::new(EntryMode::FILE); +#[php_class(name = "OpenDAL\\Operator")] +pub struct Operator(od::BlockingOperator); - format!("{:?}", metadata) +#[php_impl(rename_methods = "none")] +impl Operator { + pub fn __construct(scheme_str: String, config: HashMap) -> PhpResult { + let scheme = od::Scheme::from_str(&scheme_str).map_err(format_php_err)?; + let op = od::Operator::via_map(scheme, config).map_err(format_php_err)?; + + Ok(Operator(op.blocking())) + } + + /// Write string into given path. + pub fn write(&self, path: &str, content: String) -> PhpResult<()> { + self.0.write(path, content).map_err(format_php_err) + } + + /// Write bytes into given path, binary safe. + pub fn write_binary(&self, path: &str, content: Vec) -> PhpResult<()> { + self.0.write(path, content).map_err(format_php_err) + } + + /// Read the whole path into bytes, binary safe. + pub fn read(&self, path: &str) -> PhpResult> { + self.0.read(path).map_err(format_php_err).map(Binary::from) + } + + /// Check if this path exists or not, return 1 if exists, 0 otherwise. + pub fn is_exist(&self, path: &str) -> PhpResult { + self.0 + .is_exist(path) + .map_err(format_php_err) + .map(|b| if b { 1 } else { 0 }) + } + + /// Get current path's metadata **without cache** directly. + /// + /// # Notes + /// + /// Use `stat` if you: + /// + /// - Want detect the outside changes of path. + /// - Don't want to read from cached metadata. + pub fn stat(&self, path: &str) -> PhpResult { + self.0.stat(path).map_err(format_php_err).map(Metadata) + } + + /// Delete given path. + /// + /// # Notes + /// + /// - Delete not existing error won't return errors. + pub fn delete(&self, path: &str) -> PhpResult<()> { + self.0.delete(path).map_err(format_php_err) + } + + /// Create a dir at given path. + /// + /// # Notes + /// + /// To indicate that a path is a directory, it is compulsory to include + /// a trailing / in the path. Failure to do so may result in + /// `NotADirectory` error being returned by OpenDAL. + /// + /// # Behavior + /// + /// - Create on existing dir will succeed. + /// - Create dir is always recursive, works like `mkdir -p` + pub fn create_dir(&self, path: &str) -> PhpResult<()> { + self.0.create_dir(path).map_err(format_php_err) + } +} + +#[php_class(name = "OpenDAL\\Metadata")] +pub struct Metadata(od::Metadata); + +#[php_impl(rename_methods = "none")] +impl Metadata { + #[getter] + pub fn content_disposition(&self) -> Option { + self.0.content_disposition().map(|s| s.to_string()) + } + + /// Content length of this entry. + #[getter] + pub fn content_length(&self) -> u64 { + self.0.content_length() + } + + /// Content MD5 of this entry. + #[getter] + pub fn content_md5(&self) -> Option { + self.0.content_md5().map(|s| s.to_string()) + } + + /// Content Type of this entry. + #[getter] + pub fn content_type(&self) -> Option { + self.0.content_type().map(|s| s.to_string()) + } + + /// ETag of this entry. + #[getter] + pub fn etag(&self) -> Option { + self.0.etag().map(|s| s.to_string()) + } + + /// mode represent this entry's mode. + #[getter] + pub fn mode(&self) -> EntryMode { + EntryMode(self.0.mode()) + } +} + +#[php_class(name = "OpenDAL\\EntryMode")] +pub struct EntryMode(od::EntryMode); + +impl<'b> FromZval<'b> for EntryMode { + const TYPE: DataType = DataType::Object(Some("OpenDAL\\EntryMode")); + + fn from_zval(zval: &'b Zval) -> Option { + zval.object().and_then(|obj| obj.get_property("mode").ok()) + } +} + +#[php_impl(rename_methods = "none")] +impl EntryMode { + #[getter] + pub fn is_dir(&self) -> u8 { + match self.0.is_dir() { + true => 1, + false => 0, + } + } + + #[getter] + pub fn is_file(&self) -> u8 { + match self.0.is_file() { + true => 1, + false => 0, + } + } +} + +fn format_php_err(e: od::Error) -> PhpException { + // @todo use custom exception, we cannot use custom exception now, + // see https://github.com/davidcole1340/ext-php-rs/issues/262 + PhpException::default(e.to_string()) } #[php_module] diff --git a/bindings/php/tests/Feature/BasicIOTest.php b/bindings/php/tests/Feature/BasicIOTest.php new file mode 100644 index 00000000000..330e158e637 --- /dev/null +++ b/bindings/php/tests/Feature/BasicIOTest.php @@ -0,0 +1,133 @@ + '/tmp']); + + it('ensure file not exist', function () use ($op) { + $op->delete('test.txt'); + expect($op->is_exist('test.txt'))->toEqual(0); + }); + + it('write/read file', function () use ($op) { + $op->write('test.txt', 'hello world'); + expect($op->is_exist('test.txt'))->toEqual(1) + ->and($op->read('test.txt'))->toEqual('hello world'); + }); + + it('write/read file overwrite', function () use ($op) { + $op->write('test.txt', 'new content'); + expect($op->is_exist('test.txt'))->toEqual(1) + ->and($op->read('test.txt'))->toEqual('new content'); + }); + + it('file metadata', function () use ($op) { + $meta = $op->stat('test.txt'); + expect($meta)->toBeInstanceOf(\OpenDAL\Metadata::class) + ->and($meta->content_length)->toEqual(11) + ->and($meta->mode)->toBeInstanceOf(\OpenDAL\EntryMode::class) + ->and($meta->mode->is_file)->toEqual(1) + ->and($meta->mode->is_dir)->toEqual(0); + }); + + it('delete file', function () use ($op) { + $op->delete('test.txt'); + expect($op->is_exist('test.txt'))->toEqual(0); + }); + + it('create dir', function () use ($op) { + $op->create_dir('test/'); + expect(is_dir('/tmp/test'))->toBeTrue(); + }); +}); + +describe('basic io with memory', function () { + $op = new \OpenDAL\Operator('memory', []); + + it('ensure file not exist', function () use ($op) { + $op->delete('test.txt'); + expect($op->is_exist('test.txt'))->toEqual(0); + }); + + it('write/read file', function () use ($op) { + $op->write('test.txt', 'hello world'); + expect($op->is_exist('test.txt'))->toEqual(1) + ->and($op->read('test.txt'))->toEqual('hello world'); + }); + + it('write/read file overwrite', function () use ($op) { + $op->write('test.txt', 'new content'); + expect($op->is_exist('test.txt'))->toEqual(1) + ->and($op->read('test.txt'))->toEqual('new content'); + }); + + it('file metadata', function () use ($op) { + $meta = $op->stat('test.txt'); + expect($meta)->toBeInstanceOf(\OpenDAL\Metadata::class) + ->and($meta->content_length)->toEqual(11) + ->and($meta->mode)->toBeInstanceOf(\OpenDAL\EntryMode::class) + ->and($meta->mode->is_file)->toEqual(1) + ->and($meta->mode->is_dir)->toEqual(0); + }); + + it('delete file', function () use ($op) { + $op->delete('test.txt'); + expect($op->is_exist('test.txt'))->toEqual(0); + }); + + it('create dir', function () use ($op) { + $op->create_dir('test/'); + expect(is_dir('/tmp/test'))->toBeTrue(); + }); +}); + +describe('binary safe IO with fs', function () { + $op = new \OpenDAL\Operator('fs', ['root' => '/tmp']); + + it('write & read invalid UTF-8', function () use ($op) { + $content = "hello 🌰 \x80\x80\x80 🍋"; + $bytesArray = unpack('C*', $content); + + expect($bytesArray)->toBeArray(); + + $op->write_binary('test.txt', $bytesArray); + $content = $op->read('test.txt'); + + expect($content)->toBeString()->toEqual($content); + }); +}); + +describe('binary safe IO with memory', function () { + $op = new \OpenDAL\Operator('memory', []); + + it('write & read invalid UTF-8', function () use ($op) { + $content = "hello 🌰 \x80\x80\x80 🍋"; + $bytesArray = unpack('C*', $content); + + expect($bytesArray)->toBeArray(); + + $op->write_binary('test.txt', $bytesArray); + $content = $op->read('test.txt'); + + expect($content)->toBeString()->toEqual($content); + }); +}); diff --git a/bindings/php/tests/Pest.php b/bindings/php/tests/Pest.php new file mode 100644 index 00000000000..5916d570459 --- /dev/null +++ b/bindings/php/tests/Pest.php @@ -0,0 +1,63 @@ +in('Feature'); + +/* +|-------------------------------------------------------------------------- +| Expectations +|-------------------------------------------------------------------------- +| +| When you're writing tests, you often need to check that values meet certain conditions. The +| "expect()" function gives you access to a set of "expectations" methods that you can use +| to assert different things. Of course, you may extend the Expectation API at any time. +| +*/ + +expect()->extend('toBeOne', function () { + return $this->toBe(1); +}); + +/* +|-------------------------------------------------------------------------- +| Functions +|-------------------------------------------------------------------------- +| +| While Pest is very powerful out-of-the-box, you may have some testing code specific to your +| project that you don't want to repeat in every file. Here you can also expose helpers as +| global functions to help you to reduce the number of lines of code in your test files. +| +*/ + +function something() +{ + // .. +} diff --git a/bindings/php/tests/TestCase.php b/bindings/php/tests/TestCase.php new file mode 100644 index 00000000000..552af5dd902 --- /dev/null +++ b/bindings/php/tests/TestCase.php @@ -0,0 +1,28 @@ +toBeTrue(); }); -it('debug function works', function () { - expect(debug())->toStartWith('Metadata'); +it('class & methods exists', function ($class, $methods) { + expect(class_exists($class))->toBeTrue(); + foreach ($methods as $method) { + expect(method_exists($class, $method))->toBeTrue(); + } +})->with([ + ['OpenDAL\Operator', ['is_exist', 'read', 'write', 'delete', 'stat', 'create_dir', 'write_binary']], + ['OpenDAL\Metadata', []], + ['OpenDAL\EntryMode', []], +]); + +describe('throw exception', function () { + it('invalid driver', function () { + new \OpenDAL\Operator('invalid', []); + })->throws('Exception'); + + it('unspecified root path', function () { + new \OpenDAL\Operator('fs', []); + })->throws('Exception'); + + it('read non-exist file', function () { + $op = new \OpenDAL\Operator('fs', ['root' => '/tmp']); + $op->read('non-exist.txt'); + })->throws('Exception'); +}); + +it('initialization OpenDAL', function () { + $op = new \OpenDAL\Operator('fs', ['root' => '/tmp']); + + expect($op) + ->toBeInstanceOf(\OpenDAL\Operator::class) + ->not->toHaveProperty('op') + ->not->toThrow(Exception::class); }); + +it('invalid UTF-8 encoding', function () { + $op = new \OpenDAL\Operator('fs', ['root' => '/tmp']); + + $op->write('test.txt', 'invalid UTF-8: '.chr(0x80)); +})->throws('Exception')->expectExceptionMessage('Invalid value given for argument `content`.');