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 429972bc3 feat(service/cloudflare_kv): support cloudflare KV (#3348)
429972bc3 is described below
commit 429972bc3b101fbbefd1a78be9e0de1d77504275
Author: yi wang <[email protected]>
AuthorDate: Mon Oct 23 17:55:01 2023 +0800
feat(service/cloudflare_kv): support cloudflare KV (#3348)
* feat(service/Cloudflare kv):support cloudflare KV
init cloudflare-kv
implement builder.
Implement read
implement scan
feat(service/Cloudflare kv):support cloudflare KV
* fix: fmt
* fix ..
* fix clippy
* fix licenses
* fix: comment unused field
* fix: remove unused field
* fmt
---
core/Cargo.toml | 3 +-
core/src/services/cloudflare_kv/backend.rs | 362 +++++++++++++++++++++++++++++
core/src/services/cloudflare_kv/docs.md | 22 ++
core/src/services/cloudflare_kv/error.rs | 83 +++++++
core/src/services/cloudflare_kv/mod.rs | 21 ++
core/src/services/mod.rs | 5 +
core/src/types/scheme.rs | 4 +
7 files changed, 499 insertions(+), 1 deletion(-)
diff --git a/core/Cargo.toml b/core/Cargo.toml
index 1dd4e7935..3679f3955 100644
--- a/core/Cargo.toml
+++ b/core/Cargo.toml
@@ -116,6 +116,7 @@ services-azdls = [
"reqsign?/reqwest_request",
]
services-cacache = ["dep:cacache"]
+services-cloudflare-kv = []
services-cos = [
"dep:reqsign",
"reqsign?/services-tencent",
@@ -231,9 +232,9 @@ foundationdb = { version = "0.8.0", features = [
futures = { version = "0.3", default-features = false, features = ["std"] }
governor = { version = "0.5", optional = true, features = ["std"] }
hdrs = { version = "0.3.0", optional = true, features = ["async_file"] }
+hrana-client-proto = { version = "0.2.1", optional = true }
http = "0.2.9"
hyper = "0.14"
-hrana-client-proto = { version = "0.2.1", optional = true }
lazy-regex = { version = "3.0.1", optional = true }
log = "0.4"
madsim = { version = "0.2.21", optional = true }
diff --git a/core/src/services/cloudflare_kv/backend.rs
b/core/src/services/cloudflare_kv/backend.rs
new file mode 100644
index 000000000..e05bb3931
--- /dev/null
+++ b/core/src/services/cloudflare_kv/backend.rs
@@ -0,0 +1,362 @@
+// 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 async_trait::async_trait;
+use http::header;
+use http::Request;
+use http::StatusCode;
+use serde::Deserialize;
+
+use super::error::parse_error;
+use crate::raw::adapters::kv;
+use crate::raw::*;
+use crate::ErrorKind;
+use crate::*;
+
+#[doc = include_str!("docs.md")]
+#[derive(Default)]
+pub struct CloudflareKvBuilder {
+ /// The token used to authenticate with CloudFlare.
+ token: Option<String>,
+ /// The account ID used to authenticate with CloudFlare. Used as URI path
parameter.
+ account_id: Option<String>,
+ /// The namespace ID. Used as URI path parameter.
+ namespace_id: Option<String>,
+
+ /// The HTTP client used to communicate with CloudFlare.
+ http_client: Option<HttpClient>,
+ /// Root within this backend.
+ root: Option<String>,
+}
+
+impl Debug for CloudflareKvBuilder {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("CloudFlareKvBuilder")
+ .field("account_id", &self.account_id)
+ .field("namespace_id", &self.namespace_id)
+ .field("root", &self.root)
+ .finish()
+ }
+}
+
+impl CloudflareKvBuilder {
+ /// Set the token used to authenticate with CloudFlare.
+ pub fn token(&mut self, token: &str) -> &mut Self {
+ if !token.is_empty() {
+ self.token = Some(token.to_string())
+ }
+ self
+ }
+
+ /// Set the account ID used to authenticate with CloudFlare.
+ pub fn account_id(&mut self, account_id: &str) -> &mut Self {
+ if !account_id.is_empty() {
+ self.account_id = Some(account_id.to_string())
+ }
+ self
+ }
+
+ /// Set the namespace ID.
+ pub fn namespace_id(&mut self, namespace_id: &str) -> &mut Self {
+ if !namespace_id.is_empty() {
+ self.namespace_id = Some(namespace_id.to_string())
+ }
+ self
+ }
+
+ /// Set the root within this backend.
+ pub fn root(&mut self, root: &str) -> &mut Self {
+ if !root.is_empty() {
+ self.root = Some(root.to_string())
+ }
+ self
+ }
+}
+
+impl Builder for CloudflareKvBuilder {
+ const SCHEME: Scheme = Scheme::CloudflareKv;
+
+ type Accessor = CloudflareKvBackend;
+
+ fn from_map(map: HashMap<String, String>) -> Self {
+ let mut builder = Self::default();
+ map.get("token").map(|v| builder.token(v));
+ map.get("account_id").map(|v| builder.account_id(v));
+ map.get("namespace_id").map(|v| builder.namespace_id(v));
+ map.get("root").map(|v| builder.root(v));
+ builder
+ }
+
+ fn build(&mut self) -> Result<Self::Accessor> {
+ let authorization = match &self.token {
+ Some(token) => format_authorization_by_bearer(token)?,
+ None => return Err(Error::new(ErrorKind::ConfigInvalid, "token is
required")),
+ };
+
+ let Some(account_id) = self.account_id.clone() else {
+ return Err(Error::new(
+ ErrorKind::ConfigInvalid,
+ "account_id is required",
+ ));
+ };
+
+ let Some(namespace_id) = self.namespace_id.clone() else {
+ return Err(Error::new(
+ ErrorKind::ConfigInvalid,
+ "namespace_id is required",
+ ));
+ };
+
+ 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::CloudflareKv)
+ })?
+ };
+
+ let root = normalize_root(
+ self.root
+ .clone()
+ .unwrap_or_else(|| "/".to_string())
+ .as_str(),
+ );
+
+ let url_prefix = format!(
+
r"https://api.cloudflare.com/client/v4/accounts/{}/storage/kv/namespaces/{}",
+ account_id, namespace_id
+ );
+
+ Ok(kv::Backend::new(Adapter {
+ authorization,
+ account_id,
+ namespace_id,
+ client,
+ url_prefix,
+ })
+ .with_root(&root))
+ }
+}
+
+pub type CloudflareKvBackend = kv::Backend<Adapter>;
+
+#[derive(Clone)]
+pub struct Adapter {
+ authorization: String,
+ account_id: String,
+ namespace_id: String,
+ client: HttpClient,
+ url_prefix: String,
+}
+
+impl Debug for Adapter {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("Adapter")
+ .field("account_id", &self.account_id)
+ .field("namespace_id", &self.namespace_id)
+ .finish()
+ }
+}
+
+impl Adapter {
+ fn sign<T>(&self, mut req: Request<T>) -> Result<Request<T>> {
+ req.headers_mut()
+ .insert(header::AUTHORIZATION,
self.authorization.parse().unwrap());
+ Ok(req)
+ }
+}
+
+#[async_trait]
+impl kv::Adapter for Adapter {
+ fn metadata(&self) -> kv::Metadata {
+ kv::Metadata::new(
+ Scheme::CloudflareKv,
+ &self.namespace_id,
+ Capability {
+ read: true,
+ write: true,
+ list: true,
+
+ ..Default::default()
+ },
+ )
+ }
+
+ async fn get(&self, path: &str) -> Result<Option<Vec<u8>>> {
+ let url = format!("{}/values/{}", self.url_prefix, path);
+ let mut req = Request::get(&url);
+ req = req.header(header::CONTENT_TYPE, "application/json");
+ let mut req = req
+ .body(AsyncBody::Empty)
+ .map_err(new_request_build_error)?;
+ req = self.sign(req)?;
+ let resp = self.client.send(req).await?;
+ let status = resp.status();
+ match status {
+ StatusCode::OK => {
+ let body = resp.into_body().bytes().await?;
+ Ok(Some(body.into()))
+ }
+ _ => Err(parse_error(resp).await?),
+ }
+ }
+
+ async fn set(&self, path: &str, value: &[u8]) -> Result<()> {
+ let url = format!("{}/values/{}", self.url_prefix, path);
+ let req = Request::put(&url);
+ let multipart = Multipart::new();
+ let multipart = multipart
+
.part(FormDataPart::new("metadata").content(serde_json::Value::Null.to_string()))
+ .part(FormDataPart::new("value").content(value.to_vec()));
+ let mut req = multipart.apply(req)?;
+ req = self.sign(req)?;
+ let resp = self.client.send(req).await?;
+ let status = resp.status();
+ match status {
+ StatusCode::OK => Ok(()),
+ _ => Err(parse_error(resp).await?),
+ }
+ }
+
+ async fn delete(&self, path: &str) -> Result<()> {
+ let url = format!("{}/values/{}", self.url_prefix, path);
+ let mut req = Request::delete(&url);
+ req = req.header(header::CONTENT_TYPE, "application/json");
+ let mut req = req
+ .body(AsyncBody::Empty)
+ .map_err(new_request_build_error)?;
+ req = self.sign(req)?;
+ let resp = self.client.send(req).await?;
+ let status = resp.status();
+ match status {
+ StatusCode::OK => Ok(()),
+ _ => Err(parse_error(resp).await?),
+ }
+ }
+
+ async fn scan(&self, path: &str) -> Result<Vec<String>> {
+ let mut url = format!("{}/keys", self.url_prefix);
+ if !path.is_empty() {
+ url = format!("{}?prefix={}", url, path);
+ }
+ let mut req = Request::get(&url);
+ req = req.header(header::CONTENT_TYPE, "application/json");
+ let mut req = req
+ .body(AsyncBody::Empty)
+ .map_err(new_request_build_error)?;
+ req = self.sign(req)?;
+ let resp = self.client.send(req).await?;
+ let status = resp.status();
+ match status {
+ StatusCode::OK => {
+ let body = resp.into_body().bytes().await?;
+ let response: CfKvScanResponse =
serde_json::from_slice(&body).map_err(|e| {
+ Error::new(
+ crate::ErrorKind::Unexpected,
+ &format!("failed to parse error response: {}", e),
+ )
+ })?;
+ Ok(response.result.into_iter().map(|r| r.name).collect())
+ }
+ _ => Err(parse_error(resp).await?),
+ }
+ }
+}
+
+#[derive(Debug, Deserialize)]
+pub(crate) struct CfKvResponse {
+ pub(crate) errors: Vec<CfKvError>,
+}
+
+#[derive(Debug, Deserialize)]
+pub(crate) struct CfKvScanResponse {
+ result: Vec<CfKvScanResult>,
+ // According to
https://developers.cloudflare.com/api/operations/workers-kv-namespace-list-a-namespace'-s-keys,
result_info is used to determine if there are more keys to be listed
+ // result_info: Option<CfKvResultInfo>,
+}
+
+#[derive(Debug, Deserialize)]
+struct CfKvScanResult {
+ name: String,
+}
+
+// #[derive(Debug, Deserialize)]
+// struct CfKvResultInfo {
+// count: i64,
+// cursor: String,
+// }
+
+#[derive(Debug, Deserialize)]
+pub(crate) struct CfKvError {
+ pub(crate) code: i32,
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+
+ #[test]
+ fn test_deserialize_scan_json_response() {
+ let json_str = r#"{
+ "errors": [],
+ "messages": [],
+ "result": [
+ {
+ "expiration": 1577836800,
+ "metadata": {
+ "someMetadataKey": "someMetadataValue"
+ },
+ "name": "My-Key"
+ }
+ ],
+ "success": true,
+ "result_info": {
+ "count": 1,
+ "cursor":
"6Ck1la0VxJ0djhidm1MdX2FyDGxLKVeeHZZmORS_8XeSuhz9SjIJRaSa2lnsF01tQOHrfTGAP3R5X1Kv5iVUuMbNKhWNAXHOl6ePB0TUL8nw"
+ }
+ }"#;
+
+ let response: CfKvScanResponse =
serde_json::from_slice(json_str.as_bytes()).unwrap();
+
+ assert_eq!(response.result.len(), 1);
+ assert_eq!(response.result[0].name, "My-Key");
+ // assert!(response.result_info.is_some());
+ // if let Some(result_info) = response.result_info {
+ // assert_eq!(result_info.count, 1);
+ // assert_eq!(result_info.cursor,
"6Ck1la0VxJ0djhidm1MdX2FyDGxLKVeeHZZmORS_8XeSuhz9SjIJRaSa2lnsF01tQOHrfTGAP3R5X1Kv5iVUuMbNKhWNAXHOl6ePB0TUL8nw");
+ // }
+ }
+
+ #[test]
+ fn test_deserialize_json_response() {
+ let json_str = r#"{
+ "errors": [],
+ "messages": [],
+ "result": {},
+ "success": true
+ }"#;
+
+ let response: CfKvResponse =
serde_json::from_slice(json_str.as_bytes()).unwrap();
+
+ assert_eq!(response.errors.len(), 0);
+ }
+}
diff --git a/core/src/services/cloudflare_kv/docs.md
b/core/src/services/cloudflare_kv/docs.md
new file mode 100644
index 000000000..06c600635
--- /dev/null
+++ b/core/src/services/cloudflare_kv/docs.md
@@ -0,0 +1,22 @@
+## Capabilities
+
+This service can be used to:
+
+- [x] stat
+- [x] read
+- [x] write
+- [x] create_dir
+- [x] delete
+- [ ] copy
+- [ ] rename
+- [ ] ~~list~~
+- [x] scan
+- [ ] ~~presign~~
+- [ ] blocking
+
+## Configuration
+
+- `root`: Set the working directory of `OpenDAL`
+- `token`: Set the token of cloudflare api
+- `account_id`: Set the account identifier of cloudflare
+- `namespace_id`: Set the namespace identifier of d1
diff --git a/core/src/services/cloudflare_kv/error.rs
b/core/src/services/cloudflare_kv/error.rs
new file mode 100644
index 000000000..10f4c4aaa
--- /dev/null
+++ b/core/src/services/cloudflare_kv/error.rs
@@ -0,0 +1,83 @@
+// 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 http::StatusCode;
+
+use crate::raw::*;
+use crate::Error;
+use crate::ErrorKind;
+use crate::Result;
+
+use serde_json::de;
+
+use super::backend::CfKvError;
+use super::backend::CfKvResponse;
+
+/// Parse error response into Error.
+pub(crate) async fn parse_error(resp: Response<IncomingAsyncBody>) ->
Result<Error> {
+ let (parts, body) = resp.into_parts();
+ let bs = body.bytes().await?;
+
+ let (mut kind, mut retryable) = match parts.status {
+ StatusCode::NOT_FOUND => (ErrorKind::NotFound, false),
+ // Some services (like owncloud) return 403 while file locked.
+ StatusCode::FORBIDDEN => (ErrorKind::PermissionDenied, true),
+ // Allowing retry for resource locked.
+ StatusCode::LOCKED => (ErrorKind::Unexpected, true),
+ StatusCode::INTERNAL_SERVER_ERROR
+ | StatusCode::BAD_GATEWAY
+ | StatusCode::SERVICE_UNAVAILABLE
+ | StatusCode::GATEWAY_TIMEOUT => (ErrorKind::Unexpected, true),
+ _ => (ErrorKind::Unexpected, false),
+ };
+
+ let (message, err) = de::from_reader::<_,
CfKvResponse>(bs.clone().reader())
+ .map(|err| (format!("{err:?}"), Some(err)))
+ .unwrap_or_else(|_| (String::from_utf8_lossy(&bs).into_owned(), None));
+
+ if let Some(err) = err {
+ (kind, retryable) = parse_cfkv_error_code(err.errors).unwrap_or((kind,
retryable));
+ }
+
+ let mut err = Error::new(kind, &message);
+
+ err = with_error_response_context(err, parts);
+
+ if retryable {
+ err = err.set_temporary();
+ }
+
+ Ok(err)
+}
+
+pub(crate) fn parse_cfkv_error_code(errors: Vec<CfKvError>) ->
Option<(ErrorKind, bool)> {
+ if errors.is_empty() {
+ return None;
+ }
+
+ match errors[0].code {
+ // The request is malformed: failed to decode id.
+ 7400 => Some((ErrorKind::Unexpected, false)),
+ // no such column: Xxxx.
+ 7500 => Some((ErrorKind::NotFound, false)),
+ // Authentication error.
+ 10000 => Some((ErrorKind::PermissionDenied, false)),
+ _ => None,
+ }
+}
diff --git a/core/src/services/cloudflare_kv/mod.rs
b/core/src/services/cloudflare_kv/mod.rs
new file mode 100644
index 000000000..fa09aa3d0
--- /dev/null
+++ b/core/src/services/cloudflare_kv/mod.rs
@@ -0,0 +1,21 @@
+// 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;
+mod error;
+
+pub use backend::CloudflareKvBuilder as CloudflareKv;
diff --git a/core/src/services/mod.rs b/core/src/services/mod.rs
index 7215de590..d6d1b8080 100644
--- a/core/src/services/mod.rs
+++ b/core/src/services/mod.rs
@@ -29,6 +29,11 @@ mod azdls;
#[cfg(feature = "services-azdls")]
pub use azdls::Azdls;
+#[cfg(feature = "services-cloudflare-kv")]
+mod cloudflare_kv;
+#[cfg(feature = "services-cloudflare-kv")]
+pub use self::cloudflare_kv::CloudflareKv;
+
#[cfg(feature = "services-cos")]
mod cos;
#[cfg(feature = "services-cos")]
diff --git a/core/src/types/scheme.rs b/core/src/types/scheme.rs
index 5a6bd3088..231d61729 100644
--- a/core/src/types/scheme.rs
+++ b/core/src/types/scheme.rs
@@ -40,6 +40,8 @@ pub enum Scheme {
Azdls,
/// [cacache][crate::services::Cacache]: cacache backend support.
Cacache,
+ /// [cloudflare-kv][crate::services::CloudflareKv]: Cloudflare KV services.
+ CloudflareKv,
/// [cos][crate::services::Cos]: Tencent Cloud Object Storage services.
Cos,
/// [d1][crate::services::D1]: D1 services
@@ -271,6 +273,7 @@ impl FromStr for Scheme {
// And abfs is widely used in hadoop ecosystem, keep it for easy
to use.
"azdls" | "azdfs" | "abfs" => Ok(Scheme::Azdls),
"cacache" => Ok(Scheme::Cacache),
+ "cloudflare_kv" => Ok(Scheme::CloudflareKv),
"cos" => Ok(Scheme::Cos),
"d1" => Ok(Scheme::D1),
"dashmap" => Ok(Scheme::Dashmap),
@@ -323,6 +326,7 @@ impl From<Scheme> for &'static str {
Scheme::Azblob => "azblob",
Scheme::Azdls => "azdls",
Scheme::Cacache => "cacache",
+ Scheme::CloudflareKv => "cloudflare_kv",
Scheme::Cos => "cos",
Scheme::D1 => "d1",
Scheme::Dashmap => "dashmap",