-
Notifications
You must be signed in to change notification settings - Fork 25
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: gw3.io provider and MultiIPFSProvider (#447)
- Loading branch information
Showing
14 changed files
with
384 additions
and
28 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,7 @@ | ||
from .cid import * | ||
from .dummy import * | ||
from .gw3 import * | ||
from .multi import * | ||
from .pinata import * | ||
from .public import * | ||
from .types import * | ||
from .utils import * |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
from collections import UserString | ||
|
||
|
||
class CID(UserString): | ||
def __repr__(self): | ||
return f"{self.__class__.__name__}({self.data})" | ||
|
||
|
||
class CIDv0(CID): | ||
... | ||
|
||
|
||
class CIDv1(CID): | ||
... | ||
|
||
|
||
# @see https://github.com/multiformats/cid/blob/master/README.md#decoding-algorithm | ||
def is_cid_v0(cid: str) -> bool: | ||
return cid.startswith("Qm") and len(cid) == 46 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,38 +1,38 @@ | ||
import hashlib | ||
|
||
from web3 import Web3 | ||
from web3.module import Module | ||
from .types import CIDv0, CIDv1, IPFSError, IPFSProvider, FetchError | ||
|
||
from .types import CIDv0, CIDv1, IPFSProvider, NotFound | ||
|
||
|
||
class DummyIPFSProvider(IPFSProvider, Module): | ||
class DummyIPFSProvider(IPFSProvider): | ||
"""Dummy IPFS provider which using the local filesystem as a backend""" | ||
|
||
# pylint: disable=unreachable | ||
|
||
mempool: dict[CIDv0 | CIDv1, bytes] | ||
|
||
def __init__(self, w3: Web3) -> None: | ||
super().__init__(w3) | ||
def __init__(self) -> None: | ||
self.mempool = {} | ||
|
||
def fetch(self, cid: CIDv0 | CIDv1) -> bytes: | ||
try: | ||
return self.mempool[cid] | ||
except KeyError: | ||
try: | ||
with open(cid, mode="r") as f: | ||
with open(str(cid), mode="r") as f: | ||
return f.read().encode("utf-8") | ||
except Exception as e: | ||
raise NotFound() from e | ||
raise FetchError(cid) from e | ||
|
||
|
||
def upload(self, content: bytes, name: str | None = None) -> CIDv0 | CIDv1: | ||
raise IPFSError # FIXME: Remove after migration | ||
cid = CIDv0("Qm" + hashlib.sha256(content).hexdigest()) # XXX: Dummy. | ||
self.mempool[cid] = content | ||
return cid | ||
|
||
def pin(self, cid: CIDv0 | CIDv1) -> None: | ||
raise IPFSError # FIXME: Remove after migration | ||
content = self.fetch(cid) | ||
|
||
with open(cid, mode="w", encoding="utf-8") as f: | ||
with open(str(cid), mode="w", encoding="utf-8") as f: | ||
f.write(content.decode()) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
import base64 | ||
import hashlib | ||
import hmac | ||
import logging | ||
import time | ||
from urllib.parse import urlencode, urlparse | ||
|
||
import requests | ||
|
||
from src.providers.ipfs.cid import CIDv0, CIDv1, is_cid_v0 | ||
|
||
from .types import IPFSError, IPFSProvider, FetchError, PinError, UploadError | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
class GW3(IPFSProvider): | ||
"""gw3.io client""" | ||
|
||
ENDPOINT = "https://gw3.io" | ||
|
||
def __init__(self, access_key: str, access_secret: str, *, timeout: int) -> None: | ||
super().__init__() | ||
self.access_key = access_key | ||
self.access_secret = base64.urlsafe_b64decode(access_secret) | ||
self.timeout = timeout | ||
|
||
def fetch(self, cid: CIDv0 | CIDv1): | ||
try: | ||
resp = self._send("GET", f"{self.ENDPOINT}/ipfs/{cid}") | ||
except IPFSError as ex: | ||
raise FetchError(cid) from ex | ||
return resp.content | ||
|
||
def upload(self, content: bytes, name: str | None = None) -> CIDv0 | CIDv1: | ||
url = self._auth_upload(len(content)) | ||
try: | ||
response = requests.post(url, data=content, timeout=self.timeout) | ||
cid = response.headers["IPFS-Hash"] | ||
except IPFSError as ex: | ||
raise UploadError from ex | ||
except KeyError as ex: | ||
raise UploadError from ex | ||
|
||
return CIDv0(cid) if is_cid_v0(cid) else CIDv1(cid) | ||
|
||
def pin(self, cid: CIDv0 | CIDv1) -> None: | ||
try: | ||
self._send("POST", f"{self.ENDPOINT}/api/v0/pin/add", {"arg": str(cid)}) | ||
except IPFSError as ex: | ||
raise PinError(cid) from ex | ||
|
||
def _auth_upload(self, size: int) -> str: | ||
try: | ||
response = self._send("POST", f"{self.ENDPOINT}/ipfs/", {"size": size}) | ||
return response.json()["data"]["url"] | ||
except IPFSError as ex: | ||
raise UploadError from ex | ||
except KeyError as ex: | ||
raise UploadError from ex | ||
|
||
def _send(self, method: str, url: str, params: dict | None = None) -> requests.Response: | ||
req = self._signed_req(method, url, params) | ||
try: | ||
response = requests.Session().send(req, timeout=self.timeout) | ||
response.raise_for_status() | ||
except requests.RequestException as ex: | ||
logger.error({"msg": "Request has been failed", "error": str(ex)}) | ||
raise IPFSError from ex | ||
return response | ||
|
||
def _signed_req(self, method: str, url: str, params: dict | None = None) -> requests.PreparedRequest: | ||
params = params or {} | ||
params["ts"] = str(int(time.time())) | ||
query = urlencode(params, doseq=True) | ||
|
||
parsed_url = urlparse(url) | ||
data = "\n".join((method, parsed_url.path, query)).encode("utf-8") | ||
mac = hmac.new(self.access_secret, data, hashlib.sha256) | ||
sign = base64.urlsafe_b64encode(mac.digest()) | ||
|
||
req = requests.Request(method=method, url=url, params=params) | ||
req.headers["X-Access-Key"] = self.access_key | ||
req.headers["X-Access-Signature"] = sign.decode("utf-8") | ||
return req.prepare() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
import logging | ||
from functools import wraps | ||
from typing import Iterable | ||
|
||
from .cid import CIDv0, CIDv1 | ||
from .types import IPFSError, IPFSProvider | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
class MaxRetryError(IPFSError): | ||
... | ||
|
||
|
||
class MultiIPFSProvider(IPFSProvider): | ||
"""Fallback-driven provider for IPFS""" | ||
|
||
# NOTE: The provider is NOT thread-safe. | ||
|
||
providers: list[IPFSProvider] | ||
current_provider_index: int = 0 | ||
last_working_provider_index: int = 0 | ||
|
||
def __init__(self, providers: Iterable[IPFSProvider], *, retries: int = 3) -> None: | ||
super().__init__() | ||
self.retries = retries | ||
self.providers = list(providers) | ||
assert self.providers | ||
for p in self.providers: | ||
assert isinstance(p, IPFSProvider) | ||
|
||
@staticmethod | ||
def with_fallback(fn): | ||
@wraps(fn) | ||
def wrapped(self: "MultiIPFSProvider", *args, **kwargs): | ||
try: | ||
result = fn(self, *args, **kwargs) | ||
except IPFSError: | ||
self.current_provider_index = (self.current_provider_index + 1) % len(self.providers) | ||
if self.last_working_provider_index == self.current_provider_index: | ||
logger.error({"msg": "No more IPFS providers left to call"}) | ||
raise | ||
return wrapped(self, *args, **kwargs) | ||
|
||
self.last_working_provider_index = self.current_provider_index | ||
return result | ||
|
||
return wrapped | ||
|
||
@staticmethod | ||
def retry(fn): | ||
@wraps(fn) | ||
def wrapped(self: "MultiIPFSProvider", *args, **kwargs): | ||
retries_left = self.retries | ||
while retries_left: | ||
try: | ||
return fn(self, *args, **kwargs) | ||
except IPFSError as ex: | ||
retries_left -= 1 | ||
if not retries_left: | ||
raise MaxRetryError from ex | ||
logger.warning( | ||
{"msg": f"Retrying a failed call of {fn.__name__}, {retries_left=}", "error": str(ex)} | ||
) | ||
raise MaxRetryError | ||
|
||
return wrapped | ||
|
||
@property | ||
def provider(self) -> IPFSProvider: | ||
return self.providers[self.current_provider_index] | ||
|
||
@with_fallback | ||
@retry | ||
def fetch(self, cid: CIDv0 | CIDv1) -> bytes: | ||
return self.provider.fetch(cid) | ||
|
||
@with_fallback | ||
@retry | ||
def publish(self, content: bytes, name: str | None = None) -> CIDv0 | CIDv1: | ||
# If the current provider fails to upload or pin a file, it makes sense | ||
# to try to both upload and to pin via a different provider. | ||
return self.provider.publish(content, name) | ||
|
||
def upload(self, content: bytes, name: str | None = None) -> CIDv0 | CIDv1: | ||
# It doesn't make sense to upload a file to a different providers networks | ||
# without a guarantee the file will be available via another one. | ||
raise NotImplementedError | ||
|
||
@with_fallback | ||
@retry | ||
def pin(self, cid: CIDv0 | CIDv1) -> None: | ||
return self.provider.pin(cid) |
Oops, something went wrong.