Skip to content

Commit 9d684ee

Browse files
committed
feat(object-storage-r2): add Cloudflare R2 adapter for object storage
- Introduced ObjectStorageAdapterR2Configuration for R2 adapter config validation - Extended ObjectStorageAdapterConfiguration to support 'r2' adapter - Added ObjectStorageSecondaryAdapterR2 subclassing S3 adapter with R2-specific endpoint and region - Updated ObjectStorageFactory to include ObjectStorageServiceR2 factory method - Added tests for ObjectStorageSecondaryAdapterR2 initialization - Updated documentation to include R2 adapter details This enables support for Cloudflare R2 as an object storage backend, reusing the S3 adapter with R2-specific customizations.
1 parent b02dd22 commit 9d684ee

6 files changed

Lines changed: 144 additions & 3 deletions

File tree

libs/naas-abi-core/naas_abi_core/engine/engine_configuration/EngineConfiguration_ObjectStorageService.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,18 +67,40 @@ class ObjectStorageAdapterNaasConfiguration(BaseModel):
6767
base_prefix: str = ""
6868

6969

70+
class ObjectStorageAdapterR2Configuration(BaseModel):
71+
"""Object storage adapter Cloudflare R2 configuration.
72+
73+
object_storage_adapter:
74+
adapter: "r2"
75+
config:
76+
account_id: "{{ secret.R2_ACCOUNT_ID }}"
77+
bucket_name: "my-bucket"
78+
base_prefix: "my-prefix"
79+
access_key_id: "{{ secret.R2_ACCESS_KEY_ID }}"
80+
secret_access_key: "{{ secret.R2_SECRET_ACCESS_KEY }}"
81+
"""
82+
model_config = ConfigDict(extra="forbid")
83+
84+
account_id: str
85+
bucket_name: str
86+
access_key_id: str
87+
secret_access_key: str
88+
base_prefix: str = ""
89+
90+
7091
class ObjectStorageAdapterConfiguration(GenericLoader):
71-
adapter: Literal["fs", "s3", "naas", "custom"]
92+
adapter: Literal["fs", "s3", "naas", "r2", "custom"]
7293
config: (
7394
Union[
7495
ObjectStorageAdapterFSConfiguration,
7596
ObjectStorageAdapterS3Configuration,
7697
ObjectStorageAdapterNaasConfiguration,
98+
ObjectStorageAdapterR2Configuration,
7799
]
78100
| None
79101
) = None
80102

81-
__MAPPING: Dict[Literal["fs", "s3", "naas"], Tuple[str, str]] = {
103+
__MAPPING: Dict[Literal["fs", "s3", "naas", "r2"], Tuple[str, str]] = {
82104
"fs": (
83105
"abi.services.object_storage.adapters.secondary.ObjectStorageSecondaryAdapterFS",
84106
"ObjectStorageSecondaryAdapterFS",
@@ -91,6 +113,10 @@ class ObjectStorageAdapterConfiguration(GenericLoader):
91113
"abi.services.object_storage.adapters.secondary.ObjectStorageSecondaryAdapterNaas",
92114
"ObjectStorageSecondaryAdapterNaas",
93115
),
116+
"r2": (
117+
"abi.services.object_storage.adapters.secondary.ObjectStorageSecondaryAdapterR2",
118+
"ObjectStorageSecondaryAdapterR2",
119+
),
94120
}
95121

96122
@model_validator(mode="after")
@@ -118,6 +144,12 @@ def validate_adapter(self) -> Self:
118144
self.config,
119145
"Invalid configuration for services.object_storage.object_storage_adapter 'naas' adapter",
120146
)
147+
if self.adapter == "r2":
148+
pydantic_model_validator(
149+
ObjectStorageAdapterR2Configuration,
150+
self.config,
151+
"Invalid configuration for services.object_storage.object_storage_adapter 'r2' adapter",
152+
)
121153

122154
return self
123155

@@ -142,6 +174,11 @@ def load(self) -> IObjectStorageAdapter:
142174
ObjectStorageSecondaryAdapterNaas
143175

144176
return ObjectStorageSecondaryAdapterNaas(**self.config.model_dump())
177+
elif self.adapter == "r2":
178+
from naas_abi_core.services.object_storage.adapters.secondary.ObjectStorageSecondaryAdapterR2 import \
179+
ObjectStorageSecondaryAdapterR2
180+
181+
return ObjectStorageSecondaryAdapterR2(**self.config.model_dump())
145182
else:
146183
raise ValueError(f"Unknown adapter: {self.adapter}")
147184
# return GenericLoader(

libs/naas-abi-core/naas_abi_core/services/object_storage/AGENTS.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ object_storage/
1717
├── adapters/secondary/
1818
│ ├── ObjectStorageSecondaryAdapterFS.py
1919
│ ├── ObjectStorageSecondaryAdapterS3.py
20-
│ └── ObjectStorageSecondaryAdapterNaas.py
20+
│ ├── ObjectStorageSecondaryAdapterNaas.py
21+
│ └── ObjectStorageSecondaryAdapterR2.py
2122
└── ontologies/ # ObjectPut, ObjectDeleted
2223
```
2324

@@ -63,6 +64,7 @@ get_object_metadata(prefix, key) -> ObjectMetaData
6364
| `ObjectStorageSecondaryAdapterFS` | Local filesystem |
6465
| `ObjectStorageSecondaryAdapterS3` | AWS S3 (boto3). `endpoint_url` enables MinIO |
6566
| `ObjectStorageSecondaryAdapterNaas` | Naas workspace storage (wraps S3 with credential refresh) |
67+
| `ObjectStorageSecondaryAdapterR2` | Cloudflare R2 (subclasses S3 adapter, derives endpoint from `account_id`, forces `region_name="auto"`) |
6668

6769
## Factory (`ObjectStorageFactory.py`)
6870

@@ -75,6 +77,9 @@ ObjectStorageFactory.ObjectStorageServiceS3(
7577
ObjectStorageFactory.ObjectStorageServiceNaas(
7678
naas_api_key, workspace_id, storage_name, base_prefix="",
7779
)
80+
ObjectStorageFactory.ObjectStorageServiceR2(
81+
account_id, access_key_id, secret_access_key, bucket_name, base_prefix="",
82+
)
7883
```
7984

8085
## Tests

libs/naas-abi-core/naas_abi_core/services/object_storage/ObjectStorageFactory.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
from naas_abi_core.services.object_storage.adapters.secondary.ObjectStorageSecondaryAdapterNaas import (
55
ObjectStorageSecondaryAdapterNaas,
66
)
7+
from naas_abi_core.services.object_storage.adapters.secondary.ObjectStorageSecondaryAdapterR2 import (
8+
ObjectStorageSecondaryAdapterR2,
9+
)
710
from naas_abi_core.services.object_storage.adapters.secondary.ObjectStorageSecondaryAdapterS3 import (
811
ObjectStorageSecondaryAdapterS3,
912
)
@@ -55,3 +58,21 @@ def ObjectStorageServiceNaas(
5558
naas_api_key, workspace_id, storage_name, base_prefix
5659
)
5760
)
61+
62+
@staticmethod
63+
def ObjectStorageServiceR2(
64+
account_id: str,
65+
access_key_id: str,
66+
secret_access_key: str,
67+
bucket_name: str,
68+
base_prefix: str = "",
69+
) -> ObjectStorageService:
70+
return ObjectStorageService(
71+
ObjectStorageSecondaryAdapterR2(
72+
account_id,
73+
bucket_name,
74+
access_key_id,
75+
secret_access_key,
76+
base_prefix,
77+
)
78+
)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from naas_abi_core.services.object_storage.adapters.secondary.ObjectStorageSecondaryAdapterS3 import (
2+
ObjectStorageSecondaryAdapterS3,
3+
)
4+
5+
R2_REGION = "auto"
6+
7+
8+
class ObjectStorageSecondaryAdapterR2(ObjectStorageSecondaryAdapterS3):
9+
"""Cloudflare R2 implementation of the Object Storage adapter.
10+
11+
R2 exposes an S3-compatible API, so this adapter reuses the S3 adapter
12+
wholesale and only derives the account-scoped endpoint and the "auto"
13+
region R2 requires for request signing.
14+
"""
15+
16+
def __init__(
17+
self,
18+
account_id: str,
19+
bucket_name: str,
20+
access_key_id: str,
21+
secret_access_key: str,
22+
base_prefix: str = "",
23+
):
24+
"""Initialize R2 adapter with account id, bucket name and credentials.
25+
26+
Args:
27+
account_id (str): Cloudflare account ID (used to derive the R2 endpoint)
28+
bucket_name (str): Name of the R2 bucket to use
29+
access_key_id (str): R2 access key ID
30+
secret_access_key (str): R2 secret access key
31+
base_prefix (str, optional): Base prefix to prepend to all operations. Defaults to ""
32+
"""
33+
super().__init__(
34+
bucket_name=bucket_name,
35+
access_key_id=access_key_id,
36+
secret_access_key=secret_access_key,
37+
base_prefix=base_prefix,
38+
endpoint_url=f"https://{account_id}.r2.cloudflarestorage.com",
39+
region_name=R2_REGION,
40+
)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from naas_abi_core.services.object_storage.adapters.secondary.ObjectStorageSecondaryAdapterR2 import (
2+
ObjectStorageSecondaryAdapterR2,
3+
)
4+
5+
6+
def test_init_derives_endpoint_url_and_forces_auto_region(monkeypatch):
7+
captured_kwargs = {}
8+
9+
def fake_client(*args, **kwargs):
10+
captured_kwargs.update(kwargs)
11+
return object()
12+
13+
monkeypatch.setattr(
14+
"naas_abi_core.services.object_storage.adapters.secondary.ObjectStorageSecondaryAdapterS3.boto3.client",
15+
fake_client,
16+
)
17+
18+
adapter = ObjectStorageSecondaryAdapterR2(
19+
account_id="my-account",
20+
bucket_name="my-bucket",
21+
access_key_id="id",
22+
secret_access_key="secret",
23+
base_prefix="datastore",
24+
)
25+
26+
assert adapter.bucket_name == "my-bucket"
27+
assert adapter.base_prefix == "datastore"
28+
assert (
29+
captured_kwargs["endpoint_url"]
30+
== "https://my-account.r2.cloudflarestorage.com"
31+
)
32+
assert captured_kwargs["region_name"] == "auto"

libs/naas-abi-core/naas_abi_core/services/object_storage/adapters/secondary/ObjectStorageSecondaryAdapterS3.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def __init__(
2323
base_prefix: str = "",
2424
session_token: str | None = None,
2525
endpoint_url: str | None = None,
26+
region_name: str | None = None,
2627
):
2728
"""Initialize S3 adapter with bucket name and credentials.
2829
@@ -32,6 +33,9 @@ def __init__(
3233
secret_access_key (str): AWS secret access key
3334
base_prefix (str, optional): Base prefix to prepend to all operations. Defaults to ""
3435
session_token (str, optional): AWS session token. Defaults to None
36+
endpoint_url (str, optional): Custom endpoint (MinIO, R2, ...). Defaults to None
37+
region_name (str, optional): Region to sign requests with (e.g. "auto" for
38+
Cloudflare R2). Defaults to None
3539
"""
3640
self.bucket_name = bucket_name
3741
self.base_prefix = base_prefix.rstrip("/") # Remove trailing slash if present
@@ -43,13 +47,15 @@ def __init__(
4347
aws_secret_access_key=secret_access_key,
4448
aws_session_token=session_token,
4549
endpoint_url=endpoint_url,
50+
region_name=region_name,
4651
)
4752
else:
4853
self.s3_client = boto3.client(
4954
"s3",
5055
aws_access_key_id=access_key_id,
5156
aws_secret_access_key=secret_access_key,
5257
aws_session_token=session_token,
58+
region_name=region_name,
5359
)
5460

5561
def __get_full_key(self, prefix: str, key: str | None = None) -> str:

0 commit comments

Comments
 (0)