├── .github └── workflows │ └── python-publish.yml ├── .readthedocs.yml ├── LICENSE ├── README.md ├── apiclient ├── __init__.py ├── core.py └── methods.py ├── docs ├── index.md └── requirements.txt ├── mkdocs.yml ├── requirements.txt ├── setup.py └── tests └── tests.py /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.7' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | mkdocs: 4 | configuration: mkdocs.yml 5 | fail_on_warning: false 6 | 7 | python: 8 | version: 3.7 9 | install: 10 | - requirements: requirements.txt 11 | - requirements: docs/requirements.txt 12 | 13 | 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 FalseDev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | docs/index.md -------------------------------------------------------------------------------- /apiclient/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import * 2 | from .methods import * 3 | 4 | __author__ = "FalseDev" 5 | __title__ = "apiclient" 6 | __license__ = "MIT" 7 | __copyright__ = "Copyright 2021 FalseDev" 8 | __version__ = "0.1.2" 9 | 10 | -------------------------------------------------------------------------------- /apiclient/core.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import os 3 | from typing import (Any, Awaitable, Callable, Dict, List, Mapping, Optional, 4 | Tuple, TypeVar, Union) 5 | 6 | from httpx._client import BaseClient 7 | 8 | __all__ = ('APIRouter', 'APIClient', 'endpoint') 9 | 10 | _F = TypeVar("_F") 11 | 12 | DOCS_MODE = os.environ.get("DOCS_MODE", False) 13 | 14 | class Layer: 15 | def __init__(self, func): 16 | self.sync_func = func 17 | self.async_func = func 18 | 19 | def async_layer(self, func): 20 | self.async_func = func 21 | 22 | class Endpoint: 23 | def __init__(self, func): 24 | self.__func__ = func 25 | self.parent:Optional[RouterBase] = None 26 | 27 | def copy(self): 28 | return self.__class__(self.__func__) 29 | 30 | def __call__(self, *args, **kwargs): 31 | request = self.__func__(self.parent, *args, **kwargs) 32 | httpx_request = self.session.build_request(request.dict(self.parent)) 33 | layers = self.parent.layers + request.layers 34 | if inspect.iscoroutinefunction(self.session.send): 35 | return self.async_call(httpx_request, layers) 36 | return self.sync_call(httpx_request, layers) 37 | 38 | def sync_call(self, request, layers): 39 | res = self.session.send(request) 40 | for layer in layers: 41 | res = layer.sync_func(res) 42 | 43 | async def async_call(self, request, layers): 44 | res = self.session.send(request) 45 | for layer in layers: 46 | if inspect.iscoroutinefunction(layer.async_func): 47 | res = await layer.async_func(res) 48 | else: 49 | res = layer.async_func(res) 50 | 51 | @property 52 | def session(self): 53 | return self.parent.session 54 | 55 | class RouterMeta(type): 56 | def __new__(cls, name, bases, attrs, **kwargs): 57 | name = kwargs.pop('name', name) 58 | if kwargs.pop("no_init", False): 59 | return super().__new__(cls, name, bases, attrs, **kwargs) 60 | attrs['__routers__'] = { 61 | name: router 62 | for name, router in attrs.get('__annotations__', {}).items() 63 | if issubclass(router, APIRouter) 64 | } 65 | attrs['__endpoints__'] = { 66 | name: endpoint 67 | for name, endpoint in attrs.items() 68 | if isinstance(endpoint, Endpoint) 69 | } 70 | return super().__new__(cls, name, bases, attrs, **kwargs) 71 | 72 | 73 | class RouterBase(metaclass=RouterMeta, no_init=True): 74 | __endpoints__: Dict[str, str] 75 | __routers__: Dict[str, "APIRouter"] 76 | def __new__(cls, *args, **kwargs): 77 | self = super().__new__(cls) 78 | 79 | for name, endpoint in cls.__endpoints__.items(): 80 | new_ep = endpoint.copy() 81 | new_ep.parent = self 82 | setattr(self, name, endpoint) 83 | 84 | for name, router in cls.__routers__.items(): 85 | inst = router(self) 86 | # inst.parent = self 87 | setattr(self, name, inst) 88 | 89 | return self 90 | 91 | class APIClient(RouterBase, no_init=True): 92 | base_url: str 93 | session: BaseClient 94 | 95 | @property 96 | def url(self): 97 | return self.base_url 98 | 99 | @property 100 | def layers(self) -> List[Union[Layer, Callable]]: 101 | return getattr(self, "__layers__", []) 102 | 103 | class APIRouter(RouterBase, no_init=True): 104 | path: str 105 | def __init__(self, parent:RouterBase): 106 | self.parent = parent 107 | self.url = parent.url + self.path 108 | 109 | @property 110 | def session(self) -> BaseClient: 111 | return self.parent.session 112 | 113 | @property 114 | def layers(self) -> List[Union[Layer, Callable]]: 115 | return self.parent.layer + getattr(self, "__layers__", []) 116 | 117 | 118 | def endpoint(func: _F) -> _F: # Type check hack 119 | if DOCS_MODE: 120 | return func 121 | return Endpoint(func) 122 | -------------------------------------------------------------------------------- /apiclient/methods.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Any 3 | 4 | from httpx._types import (CookieTypes, FileTypes, HeaderTypes, QueryParamTypes, 5 | RequestData, RequestFiles) 6 | 7 | from .core import APIRouter 8 | 9 | __all__ = ('Get', 'Post', 'Put', 'Patch', 'Delete', 'Request') 10 | 11 | 12 | @dataclass 13 | class Request: 14 | url: str 15 | method: str 16 | headers: HeaderTypes = field(default_factory=dict) 17 | params: QueryParamTypes = field(default_factory=dict) 18 | data: RequestData = field(default_factory=dict) 19 | json: Any = field(default_factory=dict) 20 | files: RequestFiles = field(default_factory=dict) 21 | cookies: CookieTypes = field(default_factory=dict) 22 | 23 | absolute_url: bool = False 24 | from_base_url: bool = False 25 | 26 | def dict(self, *, parent: APIRouter): 27 | req_dict = {k: getattr(self, k) for k in ( 28 | 'method', 'headers', 'params', 'data', 'json', 'files', 'cookies')} 29 | 30 | if self.absolute_url: 31 | url = self.url 32 | elif self.from_base_url: 33 | url = parent.base_url + self.url 34 | else: 35 | url = parent.url + self.url 36 | req_dict['url'] = url 37 | 38 | return req_dict 39 | 40 | 41 | def Get( 42 | url: str, *, 43 | headers: HeaderTypes = {}, 44 | params: QueryParamTypes = {}, 45 | data: RequestData = {}, 46 | json: Any = {}, 47 | files: RequestFiles = {}, 48 | cookies: CookieTypes = {}, 49 | absolute_url: bool = False, 50 | from_base_url: bool = False, 51 | ) -> Any: 52 | return Request(url=url, method="GET", headers=headers, params=params, data=data, json=json, files=files) 53 | 54 | 55 | def Post( 56 | url: str, *, 57 | headers: HeaderTypes = {}, 58 | params: QueryParamTypes = {}, 59 | data: RequestData = {}, 60 | json: Any = {}, 61 | files: RequestFiles = {}, 62 | cookies: CookieTypes = {}, 63 | absolute_url: bool = False, 64 | from_base_url: bool = False, 65 | ) -> Any: 66 | return Request(url=url, method="POST", headers=headers, params=params, data=data, json=json, files=files) 67 | 68 | 69 | def Patch( 70 | url: str, *, 71 | headers: HeaderTypes = {}, 72 | params: QueryParamTypes = {}, 73 | data: RequestData = {}, 74 | json: Any = {}, 75 | files: RequestFiles = {}, 76 | cookies: CookieTypes = {}, 77 | absolute_url: bool = False, 78 | from_base_url: bool = False, 79 | ) -> Any: 80 | return Request(url=url, method="PATCH", headers=headers, params=params, data=data, json=json, files=files) 81 | 82 | 83 | def Put( 84 | url: str, *, 85 | headers: HeaderTypes = {}, 86 | params: QueryParamTypes = {}, 87 | data: RequestData = {}, 88 | json: Any = {}, 89 | files: RequestFiles = {}, 90 | cookies: CookieTypes = {}, 91 | absolute_url: bool = False, 92 | from_base_url: bool = False, 93 | ) -> Any: 94 | return Request(url=url, method="PUT", headers=headers, params=params, data=data, json=json, files=files) 95 | 96 | 97 | def Delete( 98 | url: str, *, 99 | headers: HeaderTypes = {}, 100 | params: QueryParamTypes = {}, 101 | data: RequestData = {}, 102 | json: Any = {}, 103 | files: RequestFiles = {}, 104 | cookies: CookieTypes = {}, 105 | absolute_url: bool = False, 106 | from_base_url: bool = False, 107 | ) -> Any: 108 | return Request(url=url, method="DELETE", headers=headers, params=params, data=data, json=json, files=files) 109 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | If you've ever tried to make an API wrapper 4 | you probably know that the code written can 5 | only be used as sync or async, well, not anymore. 6 | 7 | ## Features 8 | 9 | - **Lightweight**: Extremely lightweight and minimal 10 | - **Easy to use**: Implement features in no time with the 11 | - **Async and blocking**: Provides both async and blocking calls 12 | - **Test without a server**: Since the library internally uses httpx, it can be used to test itself using an `ASGI` or `WSGI` application. 13 | - **DRY**: _Don't repeat yourself_, helps avoid code duplication and write reusable code 14 | - **Routing**: An `APIRouter` class with simliar API to `APIClient` 15 | - **Modular**: Create reusable routers that can be added to any client, independant of each other 16 | 17 | ## Example Usage 18 | 19 | ```py 20 | 21 | from apiclient import APIClient, endpoint, Post 22 | 23 | class CodeExecClient(APIClient): 24 | base_url = "https://pathtomysite.com/api/1.0" # Note the missing / suffix 25 | @endpoint 26 | def run(self, language:str, code:str): 27 | # Do any processing with the data here! 28 | # Also note the / prefix in the url 29 | return Post("/execute", params={'lang':language, 'code':code}) 30 | 31 | 32 | # Using the API client 33 | from httpx import Client 34 | client = CodeExecClient(session=Client()) 35 | response = client.run("py", "print('hello world!')") 36 | 37 | ``` 38 | 39 | ## Documentation is under works 40 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs-material==6.2.7 2 | mkdocstrings==0.14.0 3 | 4 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Instant API Client 2 | site_description: Create API wrappers/clients in minutes, enjoying both blocking and async interfaces from one codebase! 3 | 4 | theme: 5 | name: material 6 | palette: 7 | scheme: slate 8 | primary: teal 9 | accent: purple 10 | 11 | 12 | markdown_extensions: 13 | - pymdownx.highlight 14 | - pymdownx.superfences 15 | 16 | 17 | plugins: 18 | - search 19 | - mkdocstrings: 20 | handler: python 21 | 22 | nav: 23 | - Introduction: index.md 24 | 25 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | httpx==0.16.1 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r", encoding="utf-8") as readme_file: 4 | long_description = readme_file.read() 5 | 6 | with open("requirements.txt", "r", encoding="utf-8") as req_file: 7 | requirements = req_file.readlines() 8 | 9 | setuptools.setup( 10 | name="instant-api-client", 11 | version="0.1.2", 12 | author="FalseDev", 13 | license="MIT", 14 | description="Create API wrappers/clients in minutes, enjoying both blocking and async interfaces from one codebase!", 15 | long_description=long_description, 16 | long_description_content_type="text/markdown", 17 | url="https://github.com/FalseDev/instant-api-client", 18 | packages=["apiclient"], 19 | install_requires=requirements, 20 | classifiers=[ 21 | "Programming Language :: Python :: 3", 22 | 'Intended Audience :: Developers', 23 | "License :: OSI Approved :: MIT License", 24 | "Operating System :: OS Independent", 25 | 'Topic :: Internet', 26 | 'Topic :: Software Development :: Libraries', 27 | 'Topic :: Software Development :: Libraries :: Python Modules', 28 | 'Topic :: Utilities', 29 | 30 | ], 31 | python_requires='>=3.6', 32 | ) 33 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from httpx import Client 4 | 5 | from apiclient import APIClient, APIRouter, Get, endpoint 6 | 7 | 8 | class PeopleRouter(APIRouter): 9 | path = "/people" 10 | 11 | @endpoint 12 | def get_all_people(self, *, quantity: int) -> Dict: 13 | return Get("/all", params={'quantity': quantity}) 14 | 15 | 16 | class ApiTestClient(APIClient): 17 | base_url = "https://api.github.com" 18 | people: PeopleRouter 19 | 20 | 21 | def test_request_creation(): 22 | session = Client() 23 | test_client = ApiTestClient(session=session) 24 | request = test_client.people.get_all_people.callback( 25 | test_client, quantity=10) 26 | absolute_url = request.dict(router=test_client.people)['url'] 27 | assert request.params == {'quantity': 10} 28 | assert absolute_url == "https://api.github.com/people/all" 29 | --------------------------------------------------------------------------------