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

hoslo pushed a commit to branch add-github-contents
in repository https://gitbox.apache.org/repos/asf/opendal.git

commit 1d934217e6bb81ec04d8539db757c92e1ed14c41
Author: hoslo <[email protected]>
AuthorDate: Mon Feb 26 17:36:33 2024 +0800

    feat(services/github_contents): add github contents support
---
 core/Cargo.toml                              |   1 +
 core/src/services/github_contents/backend.rs | 306 +++++++++++++++++++++++++++
 core/src/services/github_contents/core.rs    | 289 +++++++++++++++++++++++++
 core/src/services/github_contents/docs.md    |  54 +++++
 core/src/services/github_contents/error.rs   | 108 ++++++++++
 core/src/services/github_contents/lister.rs  |  66 ++++++
 core/src/services/github_contents/mod.rs     |  25 +++
 core/src/services/github_contents/writer.rs  |  64 ++++++
 core/src/services/mod.rs                     |   7 +
 core/src/types/operator/builder.rs           |   2 +
 core/src/types/scheme.rs                     |   4 +
 11 files changed, 926 insertions(+)

diff --git a/core/Cargo.toml b/core/Cargo.toml
index dbfc4cd309..b8701237b3 100644
--- a/core/Cargo.toml
+++ b/core/Cargo.toml
@@ -156,6 +156,7 @@ services-gcs = [
 ]
 services-gdrive = ["internal-path-cache"]
 services-ghac = []
+services-github-contents = []
 services-gridfs = ["dep:mongodb"]
 services-hdfs = ["dep:hdrs"]
 services-http = []
diff --git a/core/src/services/github_contents/backend.rs 
b/core/src/services/github_contents/backend.rs
new file mode 100644
index 0000000000..d9c6dee7f7
--- /dev/null
+++ b/core/src/services/github_contents/backend.rs
@@ -0,0 +1,306 @@
+// 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.
+
+use std::collections::HashMap;
+use std::fmt::Debug;
+use std::fmt::Formatter;
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use http::StatusCode;
+use log::debug;
+use serde::Deserialize;
+
+use super::core::GithubContentsCore;
+use super::error::parse_error;
+use super::lister::GithubContentsLister;
+use super::writer::GithubContentsWriter;
+use super::writer::GithubContentsWriters;
+use crate::raw::*;
+use crate::*;
+
+/// Config for backblaze GithubContents services support.
+#[derive(Default, Deserialize)]
+#[serde(default)]
+#[non_exhaustive]
+pub struct GithubContentsConfig {
+    /// root of this backend.
+    ///
+    /// All operations will happen under this root.
+    pub root: Option<String>,
+    /// Github access_token.
+    ///
+    /// required.
+    pub token: String,
+    /// Github repo owner.
+    ///
+    /// required.
+    pub owner: String,
+    /// Github repo name.
+    ///
+    /// required.
+    pub repo: String,
+}
+
+impl Debug for GithubContentsConfig {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        let mut d = f.debug_struct("GithubContentsConfig");
+
+        d.field("root", &self.root)
+            .field("owner", &self.owner)
+            .field("repo", &self.repo);
+
+        d.finish_non_exhaustive()
+    }
+}
+
+/// [github 
contents](https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28#create-or-update-file-contents)
 services support.
+#[doc = include_str!("docs.md")]
+#[derive(Default)]
+pub struct GithubContentsBuilder {
+    config: GithubContentsConfig,
+
+    http_client: Option<HttpClient>,
+}
+
+impl Debug for GithubContentsBuilder {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        let mut d = f.debug_struct("GithubContentsBuilder");
+
+        d.field("config", &self.config);
+        d.finish_non_exhaustive()
+    }
+}
+
+impl GithubContentsBuilder {
+    /// Set root of this backend.
+    ///
+    /// All operations will happen under this root.
+    pub fn root(&mut self, root: &str) -> &mut Self {
+        self.config.root = if root.is_empty() {
+            None
+        } else {
+            Some(root.to_string())
+        };
+
+        self
+    }
+
+    /// Github access_token.
+    ///
+    /// required.
+    pub fn token(&mut self, token: &str) -> &mut Self {
+        self.config.token = token.to_string();
+
+        self
+    }
+
+    /// Set Github repo owner.
+    pub fn owner(&mut self, owner: &str) -> &mut Self {
+        self.config.owner = owner.to_string();
+
+        self
+    }
+
+    /// Set Github repo name.
+    pub fn repo(&mut self, repo: &str) -> &mut Self {
+        self.config.repo = repo.to_string();
+
+        self
+    }
+
+    /// Specify the http client that used by this service.
+    ///
+    /// # Notes
+    ///
+    /// This API is part of OpenDAL's Raw API. `HttpClient` could be changed
+    /// during minor updates.
+    pub fn http_client(&mut self, client: HttpClient) -> &mut Self {
+        self.http_client = Some(client);
+        self
+    }
+}
+
+impl Builder for GithubContentsBuilder {
+    const SCHEME: Scheme = Scheme::GithubContents;
+    type Accessor = GithubContentsBackend;
+
+    /// Converts a HashMap into an GithubContentsBuilder instance.
+    ///
+    /// # Arguments
+    ///
+    /// * `map` - A HashMap containing the configuration values.
+    ///
+    /// # Returns
+    ///
+    /// Returns an instance of GithubContentsBuilder.
+    fn from_map(map: HashMap<String, String>) -> Self {
+        // Deserialize the configuration from the HashMap.
+        let config = 
GithubContentsConfig::deserialize(ConfigDeserializer::new(map))
+            .expect("config deserialize must succeed");
+
+        // Create an GithubContentsBuilder instance with the deserialized 
config.
+        GithubContentsBuilder {
+            config,
+            http_client: None,
+        }
+    }
+
+    /// Builds the backend and returns the result of GithubContentsBackend.
+    fn build(&mut self) -> Result<Self::Accessor> {
+        debug!("backend build started: {:?}", &self);
+
+        let root = 
normalize_root(&self.config.root.clone().unwrap_or_default());
+        debug!("backend use root {}", &root);
+
+        // Handle token.
+        if self.config.token.is_empty() {
+            return Err(Error::new(ErrorKind::ConfigInvalid, "token is empty")
+                .with_operation("Builder::build")
+                .with_context("service", Scheme::GithubContents));
+        }
+
+        // Handle owner.
+        if self.config.owner.is_empty() {
+            return Err(Error::new(ErrorKind::ConfigInvalid, "owner is empty")
+                .with_operation("Builder::build")
+                .with_context("service", Scheme::GithubContents));
+        }
+
+        debug!("backend use owner {}", &self.config.owner);
+
+        // Handle repo.
+        if self.config.repo.is_empty() {
+            return Err(Error::new(ErrorKind::ConfigInvalid, "repo is empty")
+                .with_operation("Builder::build")
+                .with_context("service", Scheme::GithubContents));
+        }
+
+        debug!("backend use repo {}", &self.config.repo);
+
+        let client = if let Some(client) = self.http_client.take() {
+            client
+        } else {
+            HttpClient::new().map_err(|err| {
+                err.with_operation("Builder::build")
+                    .with_context("service", Scheme::GithubContents)
+            })?
+        };
+
+        Ok(GithubContentsBackend {
+            core: Arc::new(GithubContentsCore {
+                root,
+                token: self.config.token.clone(),
+                owner: self.config.owner.clone(),
+                repo: self.config.repo.clone(),
+                client,
+            }),
+        })
+    }
+}
+
+/// Backend for GithubContents services.
+#[derive(Debug, Clone)]
+pub struct GithubContentsBackend {
+    core: Arc<GithubContentsCore>,
+}
+
+#[async_trait]
+impl Accessor for GithubContentsBackend {
+    type Reader = IncomingAsyncBody;
+
+    type Writer = GithubContentsWriters;
+
+    type Lister = oio::PageLister<GithubContentsLister>;
+
+    type BlockingReader = ();
+
+    type BlockingWriter = ();
+
+    type BlockingLister = ();
+
+    fn info(&self) -> AccessorInfo {
+        let mut am = AccessorInfo::default();
+        am.set_scheme(Scheme::GithubContents)
+            .set_root(&self.core.root)
+            .set_native_capability(Capability {
+                stat: true,
+
+                read: true,
+
+                write: true,
+                write_can_empty: true,
+
+                delete: true,
+
+                list: true,
+
+                ..Default::default()
+            });
+
+        am
+    }
+
+    async fn stat(&self, path: &str, _args: OpStat) -> Result<RpStat> {
+        let resp = self.core.stat(path).await?;
+
+        let status = resp.status();
+
+        match status {
+            StatusCode::OK => parse_into_metadata(path, 
resp.headers()).map(RpStat::new),
+            _ => Err(parse_error(resp).await?),
+        }
+    }
+
+    async fn read(&self, path: &str, _args: OpRead) -> Result<(RpRead, 
Self::Reader)> {
+        let resp = self.core.get(path).await?;
+
+        let status = resp.status();
+
+        match status {
+            StatusCode::OK => {
+                let size = parse_content_length(resp.headers())?;
+                let range = parse_content_range(resp.headers())?;
+                Ok((
+                    RpRead::new().with_size(size).with_range(range),
+                    resp.into_body(),
+                ))
+            }
+            _ => Err(parse_error(resp).await?),
+        }
+    }
+
+    async fn write(&self, path: &str, _args: OpWrite) -> Result<(RpWrite, 
Self::Writer)> {
+        let writer = GithubContentsWriter::new(self.core.clone(), 
path.to_string());
+
+        let w = oio::OneShotWriter::new(writer);
+
+        Ok((RpWrite::default(), w))
+    }
+
+    async fn delete(&self, path: &str, _: OpDelete) -> Result<RpDelete> {
+        match self.core.delete(path).await {
+            Ok(_) => Ok(RpDelete::default()),
+            Err(err) => Err(err),
+        }
+    }
+
+    async fn list(&self, path: &str, _args: OpList) -> Result<(RpList, 
Self::Lister)> {
+        let l = GithubContentsLister::new(self.core.clone(), path);
+        Ok((RpList::default(), oio::PageLister::new(l)))
+    }
+}
diff --git a/core/src/services/github_contents/core.rs 
b/core/src/services/github_contents/core.rs
new file mode 100644
index 0000000000..d6afc3f6ea
--- /dev/null
+++ b/core/src/services/github_contents/core.rs
@@ -0,0 +1,289 @@
+// 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.
+
+use std::fmt::Debug;
+use std::fmt::Formatter;
+
+use base64::Engine;
+use bytes::Bytes;
+use http::header;
+use http::request;
+use http::Request;
+use http::Response;
+use http::StatusCode;
+use serde::Deserialize;
+use serde_json::json;
+
+use crate::raw::*;
+use crate::*;
+
+use super::error::parse_error;
+
+pub(super) mod constants {
+    pub const USER_AGENT: &str = "OpenDAL";
+}
+
+/// Core of [github 
contents](https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28#create-or-update-file-contents)
 services support.
+#[derive(Clone)]
+pub struct GithubContentsCore {
+    /// The root of this core.
+    pub root: String,
+    /// Github access_token.
+    pub token: String,
+    /// Github repo owner.
+    pub owner: String,
+    /// Github repo name.
+    pub repo: String,
+
+    pub client: HttpClient,
+}
+
+impl Debug for GithubContentsCore {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("Backend")
+            .field("root", &self.root)
+            .field("owner", &self.owner)
+            .field("repo", &self.repo)
+            .finish_non_exhaustive()
+    }
+}
+
+impl GithubContentsCore {
+    #[inline]
+    pub async fn send(&self, req: Request<AsyncBody>) -> 
Result<Response<IncomingAsyncBody>> {
+        self.client.send(req).await
+    }
+
+    pub fn sign(&self, req: request::Builder) -> Result<request::Builder> {
+        let req = req.header(header::USER_AGENT, constants::USER_AGENT);
+
+        Ok(req.header(
+            header::AUTHORIZATION,
+            format_authorization_by_bearer(&self.token)?,
+        ))
+    }
+}
+
+impl GithubContentsCore {
+    pub async fn get_file_sha(&self, path: &str) -> Result<String> {
+        let resp = self.stat(path).await?;
+
+        match resp.status() {
+            StatusCode::OK => {
+                let headers = resp.headers();
+
+                let sha = parse_etag(headers)?;
+
+                let Some(sha) = sha else {
+                    return Err(Error::new(
+                        ErrorKind::Unexpected,
+                        "No ETag found in response headers",
+                    ));
+                };
+
+                Ok(sha.trim_matches('"').to_string())
+            }
+            StatusCode::NOT_FOUND => Err(Error::new(ErrorKind::NotFound, "File 
not found")),
+            _ => Err(parse_error(resp).await?),
+        }
+    }
+
+    pub async fn stat(&self, path: &str) -> 
Result<Response<IncomingAsyncBody>> {
+        let path = build_abs_path(&self.root, path);
+
+        let url = format!(
+            "https://api.github.com/repos/{}/{}/contents/{}";,
+            self.owner,
+            self.repo,
+            percent_encode_path(&path)
+        );
+
+        let req = Request::head(url);
+
+        let req = self.sign(req)?;
+
+        let req = req
+            .header("Accept", "application/vnd.github.raw+json")
+            .body(AsyncBody::Empty)
+            .map_err(new_request_build_error)?;
+
+        self.send(req).await
+    }
+
+    pub async fn get(&self, path: &str) -> Result<Response<IncomingAsyncBody>> 
{
+        let path = build_abs_path(&self.root, path);
+
+        let url = format!(
+            "https://api.github.com/repos/{}/{}/contents/{}";,
+            self.owner,
+            self.repo,
+            percent_encode_path(&path)
+        );
+
+        let req = Request::get(url);
+
+        let req = self.sign(req)?;
+
+        let req = req
+            .header("Accept", "application/vnd.github.raw+json")
+            .body(AsyncBody::Empty)
+            .map_err(new_request_build_error)?;
+
+        self.send(req).await
+    }
+
+    pub async fn upload(&self, path: &str, bs: Bytes) -> 
Result<Response<IncomingAsyncBody>> {
+        let sha = self.get_file_sha(path).await;
+
+        let path = build_abs_path(&self.root, path);
+
+        let url = format!(
+            "https://api.github.com/repos/{}/{}/contents/{}";,
+            self.owner,
+            self.repo,
+            percent_encode_path(&path)
+        );
+
+        let req = Request::put(url);
+
+        let req = self.sign(req)?;
+
+        let mut req_body = json!({
+            "message": "opendal upload",
+            "content": base64::engine::general_purpose::STANDARD.encode(&bs),
+        });
+
+        if let Ok(sha) = sha {
+            req_body["sha"] = serde_json::Value::String(sha);
+        }
+
+        let req = req
+            .header("Accept", "application/vnd.github+json")
+            .body(AsyncBody::Bytes(Bytes::from(req_body.to_string())))
+            .map_err(new_request_build_error)?;
+
+        self.send(req).await
+    }
+
+    pub async fn delete(&self, path: &str) -> Result<()> {
+        let sha = self.get_file_sha(path).await;
+
+        match sha {
+            Ok(sha) => {
+                let path = build_abs_path(&self.root, path);
+
+                let url = format!(
+                    "https://api.github.com/repos/{}/{}/contents/{}";,
+                    self.owner,
+                    self.repo,
+                    percent_encode_path(&path)
+                );
+
+                let req = Request::delete(url);
+
+                let req = self.sign(req)?;
+
+                let req_body = json!({
+                    "message": "opendal delete",
+                    "sha": sha,
+                });
+
+                let req = req
+                    .header("Accept", "application/vnd.github.object+json")
+                    .body(AsyncBody::Bytes(Bytes::from(req_body.to_string())))
+                    .map_err(new_request_build_error)?;
+
+                let resp = self.send(req).await?;
+
+                match resp.status() {
+                    StatusCode::OK => Ok(()),
+                    _ => Err(parse_error(resp).await?),
+                }
+            }
+            Err(err) => {
+                if err.kind() == ErrorKind::NotFound {
+                    return Ok(());
+                }
+                Err(err)
+            }
+        }
+    }
+
+    pub async fn list(&self, path: &str) -> Result<Vec<Entry>> {
+        let path = build_abs_path(&self.root, path);
+
+        let url = format!(
+            "https://api.github.com/repos/{}/{}/contents/{}";,
+            self.owner,
+            self.repo,
+            percent_encode_path(&path)
+        );
+
+        let req = Request::get(url);
+
+        let req = self.sign(req)?;
+
+        let req = req
+            .header("Accept", "application/vnd.github+json")
+            .body(AsyncBody::Empty)
+            .map_err(new_request_build_error)?;
+
+        let resp = self.send(req).await?;
+
+        match resp.status() {
+            StatusCode::OK => {
+                let body = resp.into_body().bytes().await?;
+                let resp: ListResponse =
+                    
serde_json::from_slice(&body).map_err(new_json_deserialize_error)?;
+
+                Ok(resp.entries)
+            }
+            _ => Err(parse_error(resp).await?),
+        }
+    }
+}
+
+#[derive(Default, Debug, Clone, Deserialize)]
+pub struct ListResponse {
+    pub entries: Vec<Entry>,
+}
+
+#[derive(Default, Debug, Clone, Deserialize)]
+pub struct Entry {
+    pub name: String,
+    pub path: String,
+    pub sha: String,
+    pub size: u64,
+    pub url: String,
+    pub html_url: String,
+    pub git_url: String,
+    pub download_url: String,
+    #[serde(rename = "type")]
+    pub type_field: String,
+    pub content: Option<String>,
+    pub encoding: Option<String>,
+    #[serde(rename = "_links")]
+    pub links: Links,
+}
+
+#[derive(Default, Debug, Clone, Deserialize)]
+pub struct Links {
+    #[serde(rename = "self")]
+    pub self_field: String,
+    pub git: String,
+    pub html: String,
+}
diff --git a/core/src/services/github_contents/docs.md 
b/core/src/services/github_contents/docs.md
new file mode 100644
index 0000000000..f5427982eb
--- /dev/null
+++ b/core/src/services/github_contents/docs.md
@@ -0,0 +1,54 @@
+## Capabilities
+
+This service can be used to:
+
+- [x] stat
+- [x] read
+- [x] write
+- [ ] create_dir
+- [x] delete
+- [ ] copy
+- [ ] rename
+- [x] list
+- [ ] scan
+- [ ] presign
+- [ ] blocking
+
+## Configuration
+
+- `root`: Set the work directory for backend
+- `token`: Github access token
+- `owner`: Github owner
+- `repo`: Github repository
+
+You can refer to [`GithubContentsBuilder`]'s docs for more information
+
+## Example
+
+### Via Builder
+
+```rust
+use anyhow::Result;
+use opendal::services::GithubContents;
+use opendal::Operator;
+
+#[tokio::main]
+async fn main() -> Result<()> {
+    // create backend builder
+    let mut builder = GithubContents::default();
+
+    // set the storage root for OpenDAL
+    builder.root("/");
+    // set the access token for Github API
+    builder.token("your_access_token");
+    // set the owner for Github
+    builder.owner("your_owner")
+    // set the repository for Github
+    builder.repo("your_repo");
+
+
+    let op: Operator = Operator::new(builder)?.finish();
+
+    Ok(())
+}
+```
diff --git a/core/src/services/github_contents/error.rs 
b/core/src/services/github_contents/error.rs
new file mode 100644
index 0000000000..61a6c65840
--- /dev/null
+++ b/core/src/services/github_contents/error.rs
@@ -0,0 +1,108 @@
+// 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.
+
+use bytes::Buf;
+use http::Response;
+use serde::Deserialize;
+
+use crate::raw::*;
+use crate::Error;
+use crate::ErrorKind;
+use crate::Result;
+
+#[derive(Default, Debug, Deserialize)]
+#[allow(dead_code)]
+struct GithubContentsError {
+    error: GithubContentsSubError,
+}
+
+#[derive(Default, Debug, Deserialize)]
+#[allow(dead_code)]
+struct GithubContentsSubError {
+    message: String,
+    documentation_url: String,
+}
+
+/// Parse error response into Error.
+pub async fn parse_error(resp: Response<IncomingAsyncBody>) -> Result<Error> {
+    let (parts, body) = resp.into_parts();
+    let bs = body.bytes().await?;
+
+    let (kind, retryable) = match parts.status.as_u16() {
+        401 | 403 => (ErrorKind::PermissionDenied, false),
+        404 => (ErrorKind::NotFound, false),
+        304 | 412 => (ErrorKind::ConditionNotMatch, false),
+        // https://github.com/apache/opendal/issues/4146
+        // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/423
+        // We should retry it when we get 423 error.
+        423 => (ErrorKind::RateLimited, true),
+        // Service like Upyun could return 499 error with a message like:
+        // Client Disconnect, we should retry it.
+        499 => (ErrorKind::Unexpected, true),
+        500 | 502 | 503 | 504 => (ErrorKind::Unexpected, true),
+        _ => (ErrorKind::Unexpected, false),
+    };
+
+    let (message, _github_content_err) =
+        serde_json::from_reader::<_, GithubContentsError>(bs.clone().reader())
+            .map(|github_content_err| (format!("{github_content_err:?}"), 
Some(github_content_err)))
+            .unwrap_or_else(|_| (String::from_utf8_lossy(&bs).into_owned(), 
None));
+
+    let mut err = Error::new(kind, &message);
+
+    err = with_error_response_context(err, parts);
+
+    if retryable {
+        err = err.set_temporary();
+    }
+
+    Ok(err)
+}
+
+#[cfg(test)]
+mod test {
+    use futures::stream;
+    use http::StatusCode;
+
+    use super::*;
+
+    #[tokio::test]
+    async fn test_parse_error() {
+        let err_res = vec![(
+            r#"{
+                "message": "Not Found",
+                "documentation_url": 
"https://docs.github.com/rest/repos/contents#get-repository-content";
+            }"#,
+            ErrorKind::NotFound,
+            StatusCode::NOT_FOUND,
+        )];
+
+        for res in err_res {
+            let bs = bytes::Bytes::from(res.0);
+            let body = IncomingAsyncBody::new(
+                Box::new(oio::into_stream(stream::iter(vec![Ok(bs.clone())]))),
+                None,
+            );
+            let resp = Response::builder().status(res.2).body(body).unwrap();
+
+            let err = parse_error(resp).await;
+
+            assert!(err.is_ok());
+            assert_eq!(err.unwrap().kind(), res.1);
+        }
+    }
+}
diff --git a/core/src/services/github_contents/lister.rs 
b/core/src/services/github_contents/lister.rs
new file mode 100644
index 0000000000..35ff23bb4e
--- /dev/null
+++ b/core/src/services/github_contents/lister.rs
@@ -0,0 +1,66 @@
+// 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.
+
+use std::sync::Arc;
+
+use async_trait::async_trait;
+
+use super::core::GithubContentsCore;
+use crate::raw::oio::Entry;
+use crate::raw::*;
+use crate::*;
+
+pub struct GithubContentsLister {
+    core: Arc<GithubContentsCore>,
+    path: String,
+}
+
+impl GithubContentsLister {
+    pub fn new(core: Arc<GithubContentsCore>, path: &str) -> Self {
+        Self {
+            core,
+
+            path: path.to_string(),
+        }
+    }
+}
+
+#[async_trait]
+impl oio::PageList for GithubContentsLister {
+    async fn next_page(&self, ctx: &mut oio::PageContext) -> Result<()> {
+        let entrys = self.core.list(&self.path).await?;
+
+        for entry in entrys {
+            let path = build_rel_path(&self.core.root, &entry.path);
+            let entry = if entry.type_field == "dir" {
+                let path = format!("{}/", path);
+                Entry::new(&path, Metadata::new(EntryMode::DIR))
+            } else {
+                let m = Metadata::new(EntryMode::FILE)
+                    .with_content_length(entry.size)
+                    .with_etag(entry.sha);
+                Entry::new(&path, m)
+            };
+
+            ctx.entries.push_back(entry);
+        }
+
+        ctx.done = true;
+
+        Ok(())
+    }
+}
diff --git a/core/src/services/github_contents/mod.rs 
b/core/src/services/github_contents/mod.rs
new file mode 100644
index 0000000000..e571afca63
--- /dev/null
+++ b/core/src/services/github_contents/mod.rs
@@ -0,0 +1,25 @@
+// 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.
+
+mod backend;
+pub use backend::GithubContentsBuilder as GithubContents;
+pub use backend::GithubContentsConfig;
+
+mod core;
+mod error;
+mod lister;
+mod writer;
diff --git a/core/src/services/github_contents/writer.rs 
b/core/src/services/github_contents/writer.rs
new file mode 100644
index 0000000000..d0fd096de1
--- /dev/null
+++ b/core/src/services/github_contents/writer.rs
@@ -0,0 +1,64 @@
+// 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.
+
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use http::StatusCode;
+
+use super::core::GithubContentsCore;
+use super::error::parse_error;
+use crate::raw::*;
+use crate::*;
+
+pub type GithubContentsWriters = oio::OneShotWriter<GithubContentsWriter>;
+
+pub struct GithubContentsWriter {
+    core: Arc<GithubContentsCore>,
+    path: String,
+}
+
+impl GithubContentsWriter {
+    pub fn new(core: Arc<GithubContentsCore>, path: String) -> Self {
+        GithubContentsWriter { core, path }
+    }
+}
+
+#[async_trait]
+impl oio::OneShotWrite for GithubContentsWriter {
+    async fn write_once(&self, bs: &dyn oio::WriteBuf) -> Result<()> {
+        let bs = bs.bytes(bs.remaining());
+
+        let path = if self.path.ends_with('/') {
+            format!("{}{}", self.path, self.path.trim_end_matches('/'))
+        } else {
+            self.path.clone()
+        };
+
+        let resp = self.core.upload(&path, bs).await?;
+
+        let status = resp.status();
+
+        match status {
+            StatusCode::OK | StatusCode::CREATED => {
+                resp.into_body().consume().await?;
+                Ok(())
+            }
+            _ => Err(parse_error(resp).await?),
+        }
+    }
+}
diff --git a/core/src/services/mod.rs b/core/src/services/mod.rs
index de5c0cef02..92d26fc1e4 100644
--- a/core/src/services/mod.rs
+++ b/core/src/services/mod.rs
@@ -229,6 +229,13 @@ mod gdrive;
 #[cfg(feature = "services-gdrive")]
 pub use gdrive::Gdrive;
 
+#[cfg(feature = "services-github-contents")]
+mod github_contents;
+#[cfg(feature = "services-github-contents")]
+pub use github_contents::GithubContents;
+#[cfg(feature = "services-github-contents")]
+pub use github_contents::GithubContentsConfig;
+
 #[cfg(feature = "services-dropbox")]
 mod dropbox;
 #[cfg(feature = "services-dropbox")]
diff --git a/core/src/types/operator/builder.rs 
b/core/src/types/operator/builder.rs
index a5767f59e3..6827742101 100644
--- a/core/src/types/operator/builder.rs
+++ b/core/src/types/operator/builder.rs
@@ -199,6 +199,8 @@ impl Operator {
             Scheme::Ghac => Self::from_map::<services::Ghac>(map)?.finish(),
             #[cfg(feature = "services-gridfs")]
             Scheme::Gridfs => 
Self::from_map::<services::Gridfs>(map)?.finish(),
+            #[cfg(feature = "services-github-contents")]
+            Scheme::GithubContents => 
Self::from_map::<services::GithubContents>(map)?.finish(),
             #[cfg(feature = "services-hdfs")]
             Scheme::Hdfs => Self::from_map::<services::Hdfs>(map)?.finish(),
             #[cfg(feature = "services-http")]
diff --git a/core/src/types/scheme.rs b/core/src/types/scheme.rs
index 6620fa8145..c55148dcf3 100644
--- a/core/src/types/scheme.rs
+++ b/core/src/types/scheme.rs
@@ -151,6 +151,8 @@ pub enum Scheme {
     Mongodb,
     /// [gridfs](crate::services::gridfs): MongoDB Gridfs Services
     Gridfs,
+    /// [Github Contents][crate::services::GithubContents]: Github contents 
support.
+    GithubContents,
     /// [Native HDFS](crate::services::hdfs_native): Hdfs Native service, 
using rust hdfs-native client for hdfs
     HdfsNative,
     /// Custom that allow users to implement services outside of OpenDAL.
@@ -338,6 +340,7 @@ impl FromStr for Scheme {
             "gdrive" => Ok(Scheme::Gdrive),
             "ghac" => Ok(Scheme::Ghac),
             "gridfs" => Ok(Scheme::Gridfs),
+            "github_contents" => Ok(Scheme::GithubContents),
             "hdfs" => Ok(Scheme::Hdfs),
             "http" | "https" => Ok(Scheme::Http),
             "huggingface" | "hf" => Ok(Scheme::Huggingface),
@@ -422,6 +425,7 @@ impl From<Scheme> for &'static str {
             Scheme::Postgresql => "postgresql",
             Scheme::Mysql => "mysql",
             Scheme::Gdrive => "gdrive",
+            Scheme::GithubContents => "github_contents",
             Scheme::Dropbox => "dropbox",
             Scheme::Redis => "redis",
             Scheme::Rocksdb => "rocksdb",

Reply via email to