Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: CI

on:
push:
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -e .
- name: Run tests
run: python -m unittest discover -s tests -v



11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ CREATE EXTENSION multicorn;
-- create a server that points at the wrapper class
CREATE SERVER coincap
FOREIGN DATA WRAPPER multicorn
OPTIONS (wrapper 'coincap_fdw.CoinCapForeignDataWrapper');
OPTIONS (wrapper 'coincap_fdw.CoinCapForeignDataWrapper',
base_url 'https://api.coincap.io/v2',
endpoint 'assets');
These options allow querying different CoinCap endpoints without changing the wrapper.
The defaults are base_url "https://api.coincap.io/v2" and endpoint "assets".

-- define a foreign table using the server
CREATE FOREIGN TABLE crypto_assets (
Expand Down Expand Up @@ -56,9 +60,10 @@ LIMIT 10;

```
coincap_fdw/
├── src/ # Python/Hy source package
├── coincap_fdw/ # Python source package
│ ├── __init__.py # Package initializer
│ └── wrapper.hy # Hy implementation of the FDW
│ ├── api.py # Helper for API requests
│ └── wrapper.hy # FDW implementation (Hy language)
├── requirements.txt # Runtime dependencies
├── setup.py # Packaging metadata
└── README.md # Project documentation (this file)
Expand Down
6 changes: 6 additions & 0 deletions coincap_fdw/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
try:
import hy # optional; allows importing .hy sources when available
except Exception: # pragma: no cover - hy not installed in minimal env
hy = None

from .wrapper import CoinCapForeignDataWrapper
13 changes: 13 additions & 0 deletions coincap_fdw/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import json
import requests

DEFAULT_BASE_URL = "https://api.coincap.io/v2"


def fetch_endpoint(endpoint: str, base_url: str = DEFAULT_BASE_URL):
"""Fetch JSON data from the given CoinCap API endpoint."""
url = f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}"
resp = requests.request("GET", url)
resp.raise_for_status()
return json.loads(resp.content.decode("utf-8"))["data"]

18 changes: 18 additions & 0 deletions coincap_fdw/wrapper.hy
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
(import [multicorn [ForeignDataWrapper]]
[coincap_fdw.api [fetch_endpoint DEFAULT_BASE_URL]])

(defn dict-filter [dict-obj selected-columns]
(setv lower-cols (lfor col selected-columns (.lower col)))
(dfor [k v] (.items dict-obj) :if (in lower-cols (.lower k)) [(.lower k) v]))

(defclass CoinCapForeignDataWrapper [ForeignDataWrapper]
(defn --init-- [self options columns]
(-> (super CoinCapForeignDataWrapper self) (.--init-- options columns))
(setv self.columns columns)
(setv self.base-url (get options "base_url" DEFAULT_BASE_URL))
(setv self.endpoint (get options "endpoint" "assets")))

(defn execute [self quals columns]
(setv assets-list (fetch_endpoint self.endpoint self.base-url))
(lfor asset assets-list (dict-filter asset self.columns)))

20 changes: 20 additions & 0 deletions coincap_fdw/wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from multicorn import ForeignDataWrapper
from .api import fetch_endpoint, DEFAULT_BASE_URL


def dict_filter(dict_obj, selected_columns):
lower_columns = [col.lower() for col in selected_columns]
return {k.lower(): v for k, v in dict_obj.items() if k.lower() in lower_columns}


class CoinCapForeignDataWrapper(ForeignDataWrapper):
def __init__(self, options, columns):
super().__init__(options, columns)
self.columns = columns
self.base_url = options.get("base_url", DEFAULT_BASE_URL)
self.endpoint = options.get("endpoint", "assets")

def execute(self, quals, columns):
assets_list = fetch_endpoint(self.endpoint, self.base_url)
return [dict_filter(asset, self.columns) for asset in assets_list]

3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
requests==2.25.1

hy==0.20.0
requests==2.25.1
33 changes: 17 additions & 16 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import subprocess
from setuptools import setup, find_packages, Extension
from setuptools import setup

setup(
name='coincap_fdw',
version='0.0.1',
author='sabino',
author_email='sabino@secret.fyi',
url='https://github.com/sabino/coincap_fdw',
license='WTFPL',
packages=['coincap_fdw'],
install_requires=[
'requests==2.25.1',
'hy==0.20.0'
],
package_data={'coincap_fdw': ['*.hy']},
package_dir={'coincap_fdw': 'src'}
)
name='coincap_fdw',
version='0.0.1',
author='sabino',
author_email='sabino@secret.fyi',
url='https://github.com/sabino/coincap_fdw',
license='WTFPL',
packages=['coincap_fdw'],
install_requires=[
'requests==2.25.1',
'hy==0.20.0'
],
package_data={'coincap_fdw': ['*.py', '*.hy']},
package_dir={'coincap_fdw': 'coincap_fdw'}
)


2 changes: 0 additions & 2 deletions src/__init__.py

This file was deleted.

32 changes: 0 additions & 32 deletions src/wrapper.hy

This file was deleted.

67 changes: 67 additions & 0 deletions tests/test_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import json
import unittest
from unittest.mock import patch
import sys
from types import SimpleNamespace
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'coincap_fdw'))

class FakeForeignDataWrapper:
def __init__(self, *args, **kwargs):
pass

sys.modules.setdefault('multicorn', SimpleNamespace(ForeignDataWrapper=FakeForeignDataWrapper))
sys.modules.setdefault('requests', SimpleNamespace(request=lambda *a, **k: None))

from coincap_fdw.api import fetch_endpoint, DEFAULT_BASE_URL
from coincap_fdw.wrapper import CoinCapForeignDataWrapper


class DummyResponse:
def __init__(self, data):
self.content = json.dumps({'data': data}).encode('utf-8')

def raise_for_status(self):
pass


def make_mock_request(expected_url, data):
def _request(method, url):
assert method == 'GET'
assert url == expected_url
return DummyResponse(data)
return _request


class TestAPI(unittest.TestCase):
def test_fetch_endpoint(self):
data = [{'id': 'btc'}]
url = f"{DEFAULT_BASE_URL}/assets"
with patch('requests.request', make_mock_request(url, data)):
self.assertEqual(fetch_endpoint('assets'), data)


class TestWrapper(unittest.TestCase):
def test_execute_defaults(self):
data = [{'id': 'btc'}]
url = f"{DEFAULT_BASE_URL}/assets"
with patch('requests.request', make_mock_request(url, data)):
wrapper = CoinCapForeignDataWrapper({}, {'id': {}})
rows = list(wrapper.execute({}, {}))
self.assertEqual(rows, [{'id': 'btc'}])

def test_execute_custom(self):
data = [{'id': 'btc', 'name': 'Bitcoin'}]
url = 'https://custom/api/coins'
with patch('requests.request', make_mock_request(url, data)):
wrapper = CoinCapForeignDataWrapper({'base_url': 'https://custom/api',
'endpoint': 'coins'},
{'id': {}, 'name': {}})
rows = list(wrapper.execute({}, {}))
self.assertEqual(rows, [{'id': 'btc', 'name': 'Bitcoin'}])


if __name__ == '__main__':
unittest.main()