This is an automated email from the ASF dual-hosted git repository.

xuanwo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-opendal.git


The following commit(s) were added to refs/heads/main by this push:
     new 321a2f1ee feat(binding/php): Add basic io (#2782)
321a2f1ee is described below

commit 321a2f1ee056870f38d531b37d9e43182459dbda
Author: Lianbo <[email protected]>
AuthorDate: Tue Aug 8 15:47:00 2023 +0800

    feat(binding/php): Add basic io (#2782)
    
    * Basic IO
    
    * Finish basic io
    
    * Add license
    
    * Rebase
    
    * chore:rename api
    
    * feat: add invalid uft-8 test
    
    * feat: binary safe io
    
    * feat: memory test and binary safe for `read` api
    
    * chore: format license
---
 Cargo.lock                                         | 186 +++++++++++++++++++++
 Cargo.toml                                         |   1 +
 bindings/php/Cargo.toml                            |   2 +-
 bindings/php/composer.json                         |   2 +-
 bindings/php/opendal-php.stubs.php                 | 114 ++++++++++++-
 bindings/php/src/lib.rs                            | 160 +++++++++++++++++-
 bindings/php/tests/Feature/BasicIOTest.php         | 133 +++++++++++++++
 bindings/php/tests/Pest.php                        |  63 +++++++
 .../{opendal-php.stubs.php => tests/TestCase.php}  |   9 +-
 bindings/php/tests/Unit/BasicTest.php              |  41 ++++-
 10 files changed, 697 insertions(+), 14 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 36cdbc7d7..b84f14289 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 2c99a9699..98a3389f5 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 ec9f0a2d3..8cb0de743 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 1f8157703..2126c6ffb 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 c8e44df26..8b78b8030 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 288faeb33..dbb85a8e6 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<String, String>) -> 
PhpResult<Self> {
+        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<u8>) -> 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<Binary<u8>> {
+        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<u8> {
+        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<Metadata> {
+        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<String> {
+        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<String> {
+        self.0.content_md5().map(|s| s.to_string())
+    }
+
+    /// Content Type of this entry.
+    #[getter]
+    pub fn content_type(&self) -> Option<String> {
+        self.0.content_type().map(|s| s.to_string())
+    }
+
+    /// ETag of this entry.
+    #[getter]
+    pub fn etag(&self) -> Option<String> {
+        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<Self> {
+        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 000000000..330e158e6
--- /dev/null
+++ b/bindings/php/tests/Feature/BasicIOTest.php
@@ -0,0 +1,133 @@
+<?php
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// describe does not support dataset yet
+
+describe('basic io with fs', function () {
+    $op = new \OpenDAL\Operator('fs', ['root' => '/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 000000000..5916d5704
--- /dev/null
+++ b/bindings/php/tests/Pest.php
@@ -0,0 +1,63 @@
+<?php
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/*
+|--------------------------------------------------------------------------
+| Test Case
+|--------------------------------------------------------------------------
+|
+| The closure you provide to your test functions is always bound to a specific 
PHPUnit test
+| case class. By default, that class is "PHPUnit\Framework\TestCase". Of 
course, you may
+| need to change it using the "uses()" function to bind a different classes or 
traits.
+|
+*/
+
+// uses(Tests\TestCase::class)->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/opendal-php.stubs.php 
b/bindings/php/tests/TestCase.php
similarity index 87%
copy from bindings/php/opendal-php.stubs.php
copy to bindings/php/tests/TestCase.php
index c8e44df26..552af5dd9 100644
--- a/bindings/php/opendal-php.stubs.php
+++ b/bindings/php/tests/TestCase.php
@@ -18,8 +18,11 @@
  * under the License.
  */
 
-// Stubs for opendal-php
+namespace Tests;
 
-namespace {
-    function debug(): string {}
+use PHPUnit\Framework\TestCase as BaseTestCase;
+
+abstract class TestCase extends BaseTestCase
+{
+    //
 }
diff --git a/bindings/php/tests/Unit/BasicTest.php 
b/bindings/php/tests/Unit/BasicTest.php
index 503f1c972..a9f04c495 100644
--- a/bindings/php/tests/Unit/BasicTest.php
+++ b/bindings/php/tests/Unit/BasicTest.php
@@ -22,6 +22,43 @@ it('opendal-php extension loaded', function () {
     expect(extension_loaded('opendal-php'))->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`.');

Reply via email to