├── tests ├── __init__.py ├── conftest.py ├── test_decorators.py ├── test_api.py └── test_resource.py ├── simple_rest_client ├── __init__.py ├── models.py ├── exceptions.py ├── decorators.py ├── api.py ├── request.py └── resource.py ├── MANIFEST.in ├── requirements.txt ├── pytest.ini ├── docs ├── installation.rst ├── index.rst ├── Makefile ├── conf.py └── quickstart.rst ├── setup.cfg ├── .readthedocs.yaml ├── requirements-dev.txt ├── pyproject.toml ├── examples ├── httpbin.py ├── httpbin_disable_ssl.py ├── async_httpbin.py ├── async_httpbin_disable_ssl.py ├── github.py └── async_github.py ├── .github └── workflows │ └── pythonapp.yml ├── README.rst ├── LICENSE ├── .pre-commit-config.yaml ├── .gitignore ├── Makefile ├── setup.py └── CHANGES.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /simple_rest_client/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include CHANGES.rst 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | httpx>=0.23.0 2 | python-slugify>=8.0.1 3 | python-status>=1.0.1 4 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -vvv --cov=simple_rest_client --cov-report=term-missing 3 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Install the latest stable release via pip:: 5 | 6 | pip install simple-rest-client 7 | 8 | python-simple-rest-client runs with `Python 3.8+`. 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [isort] 2 | line_length=110 3 | multi_line_output=3 4 | include_trailing_comma=True 5 | known_localfolder=simple_rest_client,tests 6 | sections=FUTURE,STDLIB,THIRDPARTY,LOCALFOLDER 7 | default_section=THIRDPARTY 8 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-22.04 4 | tools: 5 | python: "3.11" 6 | sphinx: 7 | configuration: docs/conf.py 8 | python: 9 | install: 10 | - requirements: requirements-dev.txt 11 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | Sphinx 3 | asyncmock 4 | black 5 | codecov 6 | flake8 7 | isort 8 | pre-commit 9 | pytest 10 | pytest-asyncio 11 | pytest-cov 12 | pytest-httpserver 13 | twine 14 | wheel 15 | -------------------------------------------------------------------------------- /simple_rest_client/models.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | Request = namedtuple("Request", ["url", "method", "params", "body", "headers", "timeout", "kwargs"]) 4 | Response = namedtuple("Response", ["url", "method", "body", "headers", "status_code", "client_response"]) 5 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 110 3 | target-version = ['py311'] 4 | 5 | [tool.isort] 6 | profile = 'black' 7 | line_length = 110 8 | force_alphabetical_sort_within_sections = true 9 | sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] 10 | default_section = "THIRDPARTY" 11 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. python-simple-rest-client documentation master file, created by 2 | sphinx-quickstart on Sat Apr 15 17:57:28 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to python-simple-rest-client's documentation! 7 | ===================================================== 8 | 9 | Simple REST client for python 3.8+, supports sync and asyncio (with httpx) requests 10 | 11 | 12 | Contents 13 | --------- 14 | .. toctree:: 15 | :maxdepth: 2 16 | 17 | installation 18 | quickstart 19 | -------------------------------------------------------------------------------- /simple_rest_client/exceptions.py: -------------------------------------------------------------------------------- 1 | class ActionNotFound(Exception): 2 | pass 3 | 4 | 5 | class ActionURLMatchError(Exception): 6 | pass 7 | 8 | 9 | class ClientConnectionError(Exception): 10 | pass 11 | 12 | 13 | class ErrorWithResponse(Exception): 14 | def __init__(self, message, response): 15 | self.message = message 16 | self.response = response 17 | 18 | 19 | class ClientError(ErrorWithResponse): 20 | pass 21 | 22 | 23 | class AuthError(ClientError): 24 | pass 25 | 26 | 27 | class NotFoundError(ClientError): 28 | pass 29 | 30 | 31 | class ServerError(ErrorWithResponse): 32 | pass 33 | -------------------------------------------------------------------------------- /examples/httpbin.py: -------------------------------------------------------------------------------- 1 | from simple_rest_client.api import API 2 | from simple_rest_client.resource import Resource 3 | 4 | 5 | class BasicAuthResource(Resource): 6 | actions = {"retrieve": {"method": "GET", "url": "basic-auth/{}/{}"}} 7 | 8 | 9 | # https://httpbin.org/ 10 | auth = ("username", "password") 11 | httpbin_api = API(api_root_url="https://httpbin.org/") 12 | httpbin_api.add_resource(resource_name="basic_auth", resource_class=BasicAuthResource) 13 | print( 14 | "httpbin_api.basic_auth.retrieve={!r}".format( 15 | httpbin_api.basic_auth.retrieve("username", "password", auth=auth).body 16 | ) 17 | ) 18 | httpbin_api.close_client() 19 | -------------------------------------------------------------------------------- /examples/httpbin_disable_ssl.py: -------------------------------------------------------------------------------- 1 | from simple_rest_client.api import API 2 | from simple_rest_client.resource import Resource 3 | 4 | 5 | class BasicAuthResource(Resource): 6 | actions = {"retrieve": {"method": "GET", "url": "basic-auth/{}/{}"}} 7 | 8 | 9 | # https://httpbin.org/ 10 | auth = ("username", "password") 11 | httpbin_api = API(api_root_url="https://httpbin.org/", ssl_verify=False) 12 | httpbin_api.add_resource(resource_name="basic_auth", resource_class=BasicAuthResource) 13 | print( 14 | "httpbin_api.basic_auth.retrieve={!r}".format( 15 | httpbin_api.basic_auth.retrieve("username", "password", auth=auth).body 16 | ) 17 | ) 18 | httpbin_api.close_client() 19 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = python-simple-rest-client 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /.github/workflows/pythonapp.yml: -------------------------------------------------------------------------------- 1 | name: main-workflow 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install -r requirements-dev.txt 22 | - name: pre-commit lint 23 | run: make lint 24 | - name: pytest 25 | run: pytest --cov-report=xml 26 | -------------------------------------------------------------------------------- /examples/async_httpbin.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from simple_rest_client.api import API 4 | from simple_rest_client.resource import AsyncResource 5 | 6 | 7 | class BasicAuthResource(AsyncResource): 8 | actions = {"retrieve": {"method": "GET", "url": "basic-auth/{}/{}"}} 9 | 10 | 11 | # https://httpbin.org/ 12 | auth = ("username", "password") 13 | httpbin_api = API(api_root_url="https://httpbin.org/") 14 | httpbin_api.add_resource(resource_name="basic_auth", resource_class=BasicAuthResource) 15 | 16 | 17 | async def main(): 18 | response = await httpbin_api.basic_auth.retrieve("username", "password", auth=auth) 19 | print("httpbin_api.basic_auth.retrieve={!r}".format(response.body)) 20 | await httpbin_api.aclose_client() 21 | 22 | 23 | asyncio.run(main()) 24 | -------------------------------------------------------------------------------- /examples/async_httpbin_disable_ssl.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from simple_rest_client.api import API 4 | from simple_rest_client.resource import AsyncResource 5 | 6 | 7 | class BasicAuthResource(AsyncResource): 8 | actions = {"retrieve": {"method": "GET", "url": "basic-auth/{}/{}"}} 9 | 10 | 11 | # https://httpbin.org/ 12 | auth = ("username", "password") 13 | httpbin_api = API(api_root_url="https://httpbin.org/", ssl_verify=False) 14 | httpbin_api.add_resource(resource_name="basic_auth", resource_class=BasicAuthResource) 15 | 16 | 17 | async def main(): 18 | response = await httpbin_api.basic_auth.retrieve("username", "password", auth=auth) 19 | print("httpbin_api.basic_auth.retrieve={!r}".format(response.body)) 20 | await httpbin_api.aclose_client() 21 | 22 | 23 | asyncio.run(main()) 24 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Simple Rest Client 2 | ================== 3 | 4 | |Github Actions Status| 5 | 6 | ---- 7 | 8 | Simple REST client for python 3.8+. 9 | 10 | 11 | How to install 12 | -------------- 13 | 14 | .. code:: shell 15 | 16 | pip install simple-rest-client 17 | 18 | 19 | Documentation 20 | -------------- 21 | 22 | Check out documentation at `Read the Docs`_ website. 23 | 24 | .. _`Read the Docs`: https://python-simple-rest-client.readthedocs.io/en/latest/ 25 | 26 | 27 | Projects using 28 | -------------- 29 | 30 | - `python-vindi`_ 31 | - `sentry-patrol`_ 32 | 33 | .. _`python-vindi`: https://github.com/allisson/python-vindi 34 | .. _`sentry-patrol`: https://github.com/daneoshiga/sentry-patrol 35 | 36 | .. |Github Actions Status| image:: https://github.com/allisson/python-simple-rest-client/workflows/main-workflow/badge.svg 37 | :target: https://github.com/allisson/python-simple-rest-client/actions 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Allisson Azevedo 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 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: check-ast 6 | - id: fix-byte-order-marker 7 | - id: check-docstring-first 8 | - id: check-json 9 | - id: check-merge-conflict 10 | - id: check-symlinks 11 | - id: check-toml 12 | - id: check-vcs-permalinks 13 | - id: check-xml 14 | - id: check-yaml 15 | - id: debug-statements 16 | - id: destroyed-symlinks 17 | - id: end-of-file-fixer 18 | - id: trailing-whitespace 19 | 20 | - repo: https://github.com/MarcoGorelli/absolufy-imports 21 | rev: v0.3.1 22 | hooks: 23 | - id: absolufy-imports 24 | 25 | - repo: https://github.com/pycqa/isort 26 | rev: 5.12.0 27 | hooks: 28 | - id: isort 29 | args: ["--overwrite-in-place"] 30 | 31 | - repo: https://github.com/psf/black 32 | rev: 23.9.1 33 | hooks: 34 | - id: black 35 | args: ["--line-length=110"] 36 | 37 | - repo: https://github.com/pycqa/flake8 38 | rev: 6.1.0 39 | hooks: 40 | - id: flake8 41 | args: ["--max-line-length=110", "--ignore=E203,E501,W503"] 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .pytest_cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | .venv/ 85 | 86 | # Spyder project settings 87 | .spyderproject 88 | 89 | # Rope project settings 90 | .ropeproject 91 | 92 | .DS_Store 93 | .vscode/ 94 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | PYTHON ?= $(shell which python) 4 | 5 | 6 | .PHONY: help 7 | help: ## Prints help for available target rule 8 | $(info Available target rules:) 9 | @echo 10 | @cat $(MAKEFILE_LIST) | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' 11 | 12 | 13 | .PHONY: clean-pyc 14 | clean-pyc: 15 | $(info Cleaning Python files..) 16 | @find . -iname '*.py[co]' -delete 17 | @find . -iname '__pycache__' -delete 18 | @find . -iname '.coverage' -delete 19 | @rm -rf htmlcov/ 20 | 21 | 22 | .PHONY: clean-dist 23 | clean-dist: 24 | $(info Cleaning Python distribution files..) 25 | @rm -rf dist/ 26 | @rm -rf build/ 27 | @rm -rf *.egg-info 28 | 29 | 30 | .PHONY: clean 31 | clean: clean-pyc clean-dist ## Cleans project building and caching files 32 | 33 | 34 | .PHONY: test 35 | test: ## Runs project tests using Pytest 36 | $(info Running project tests..) 37 | py.test -vvv --cov=simple_rest_client tests 38 | 39 | 40 | .PHONY: dist 41 | dist: clean 42 | $(info Building Python distribution..) 43 | $(PYTHON) setup.py sdist 44 | $(PYTHON) setup.py bdist_wheel 45 | 46 | 47 | .PHONY: release 48 | release: dist ## Generates a new project release 49 | $(info Generating a new project release..) 50 | git tag `python setup.py -q version` 51 | git push origin `python setup.py -q version` 52 | twine upload dist/* 53 | 54 | 55 | .PHONY: lint 56 | lint: ## Runs Python lint on the source code 57 | $(info Running lint against project..) 58 | SKIP=no-commit-to-branch pre-commit run -a -v 59 | -------------------------------------------------------------------------------- /examples/github.py: -------------------------------------------------------------------------------- 1 | from simple_rest_client.api import API 2 | from simple_rest_client.resource import Resource 3 | 4 | 5 | class EventResource(Resource): 6 | actions = { 7 | "public_events": {"method": "GET", "url": "events"}, 8 | "repository_events": {"method": "GET", "url": "/repos/{}/{}/events"}, 9 | "repository_issues_events": {"method": "GET", "url": "/repos/{}/{}/issues/events"}, 10 | "public_network_events": {"method": "GET", "url": "/networks/{}/{}/events"}, 11 | "public_organization_events": {"method": "GET", "url": "/orgs/{}/events"}, 12 | "user_received_events": {"method": "GET", "url": "/users/{}/received_events"}, 13 | "public_user_received_events": {"method": "GET", "url": "/users/{}/received_events/public"}, 14 | "user_events": {"method": "GET", "url": "/users/{}/events"}, 15 | "public_user_events": {"method": "GET", "url": "/users/{}/events/public"}, 16 | "organization_events": {"method": "GET", "url": "/users/{}/events/orgs/{}"}, 17 | } 18 | 19 | 20 | # https://github.com/settings/tokens 21 | headers = {"Authorization": "token valid-token"} 22 | github_api = API(api_root_url="https://api.github.com", headers=headers, json_encode_body=True) 23 | github_api.add_resource(resource_name="events", resource_class=EventResource) 24 | print("github_api.events.public_events={!r}".format(github_api.events.public_events())) 25 | print( 26 | "github_api.events.repository_events={!r}".format( 27 | github_api.events.repository_events("allisson", "python-simple-rest-client") 28 | ) 29 | ) 30 | github_api.close_client() 31 | -------------------------------------------------------------------------------- /examples/async_github.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from simple_rest_client.api import API 4 | from simple_rest_client.resource import AsyncResource 5 | 6 | 7 | class EventResource(AsyncResource): 8 | actions = { 9 | "public_events": {"method": "GET", "url": "events"}, 10 | "repository_events": {"method": "GET", "url": "/repos/{}/{}/events"}, 11 | "repository_issues_events": {"method": "GET", "url": "/repos/{}/{}/issues/events"}, 12 | "public_network_events": {"method": "GET", "url": "/networks/{}/{}/events"}, 13 | "public_organization_events": {"method": "GET", "url": "/orgs/{}/events"}, 14 | "user_received_events": {"method": "GET", "url": "/users/{}/received_events"}, 15 | "public_user_received_events": {"method": "GET", "url": "/users/{}/received_events/public"}, 16 | "user_events": {"method": "GET", "url": "/users/{}/events"}, 17 | "public_user_events": {"method": "GET", "url": "/users/{}/events/public"}, 18 | "organization_events": {"method": "GET", "url": "/users/{}/events/orgs/{}"}, 19 | } 20 | 21 | 22 | # https://github.com/settings/tokens 23 | default_params = {"access_token": "valid-token"} 24 | github_api = API(api_root_url="https://api.github.com", params=default_params, json_encode_body=True) 25 | github_api.add_resource(resource_name="events", resource_class=EventResource) 26 | 27 | 28 | async def main(): 29 | print("github_api.events.public_events={!r}".format(await github_api.events.public_events())) 30 | print( 31 | "github_api.events.repository_events={!r}".format( 32 | await github_api.events.repository_events("allisson", "python-simple-rest-client") 33 | ) 34 | ) 35 | await github_api.aclose_client() 36 | 37 | 38 | asyncio.run(main()) 39 | -------------------------------------------------------------------------------- /simple_rest_client/decorators.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from functools import wraps 3 | 4 | import httpx 5 | import status 6 | 7 | from simple_rest_client.exceptions import ( 8 | AuthError, 9 | ClientConnectionError, 10 | ClientError, 11 | NotFoundError, 12 | ServerError, 13 | ) 14 | 15 | logger = logging.getLogger(__name__) 16 | client_connection_exceptions = (httpx.RequestError,) 17 | 18 | 19 | def validate_response(response): 20 | error_suffix = " response={!r}".format(response) 21 | if response.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN): 22 | raise AuthError("operation=auth_error," + error_suffix, response) 23 | if response.status_code == status.HTTP_404_NOT_FOUND: 24 | raise NotFoundError("operation=not_found_error," + error_suffix, response) 25 | if status.is_client_error(code=response.status_code): 26 | raise ClientError("operation=client_error," + error_suffix, response) 27 | if status.is_server_error(code=response.status_code): 28 | raise ServerError("operation=server_error," + error_suffix, response) 29 | 30 | 31 | def handle_request_error(f): 32 | @wraps(f) 33 | def wrapper(*args, **kwargs): 34 | try: 35 | response = f(*args, **kwargs) 36 | except client_connection_exceptions as exc: 37 | logger.exception(exc) 38 | raise ClientConnectionError(exc) 39 | 40 | validate_response(response) 41 | 42 | return response 43 | 44 | return wrapper 45 | 46 | 47 | def handle_async_request_error(f): 48 | async def wrapper(*args, **kwargs): 49 | try: 50 | response = await f(*args, **kwargs) 51 | except client_connection_exceptions as exc: 52 | logger.exception(exc) 53 | raise ClientConnectionError(exc) 54 | 55 | validate_response(response) 56 | 57 | return response 58 | 59 | return wrapper 60 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from simple_rest_client.api import API 6 | from simple_rest_client.resource import AsyncResource, BaseResource, Resource 7 | 8 | 9 | @pytest.fixture 10 | def base_resource(): 11 | return BaseResource 12 | 13 | 14 | custom_actions = { 15 | "list": {"method": "GET", "url": "{}/users"}, 16 | "create": {"method": "POST", "url": "{}/users"}, 17 | "retrieve": {"method": "GET", "url": "{}/users/{}"}, 18 | "update": {"method": "PUT", "url": "{}/users/{}"}, 19 | "partial_update": {"method": "PATCH", "url": "{}/users/{}"}, 20 | "destroy": {"method": "DELETE", "url": "{}/users/{}"}, 21 | } 22 | 23 | 24 | @pytest.fixture 25 | def actions(): 26 | return custom_actions 27 | 28 | 29 | class CustomResource(Resource): 30 | actions = custom_actions 31 | 32 | 33 | @pytest.fixture 34 | def custom_resource(): 35 | return CustomResource 36 | 37 | 38 | @pytest.fixture 39 | def reqres_resource(httpserver): 40 | return Resource(api_root_url=httpserver.url_for("/api/"), resource_name="users", json_encode_body=True) 41 | 42 | 43 | @pytest.fixture 44 | def reqres_async_resource(httpserver): 45 | return AsyncResource( 46 | api_root_url=httpserver.url_for("/api/"), resource_name="users", json_encode_body=True 47 | ) 48 | 49 | 50 | @pytest.fixture 51 | def api(httpserver): 52 | return API(api_root_url=httpserver.url_for("/api/"), json_encode_body=True) 53 | 54 | 55 | @pytest.fixture 56 | def reqres_api(api): 57 | api.add_resource(resource_name="users") 58 | return api 59 | 60 | 61 | @pytest.fixture 62 | def reqres_async_api(api): 63 | api.add_resource(resource_name="users", resource_class=AsyncResource) 64 | return api 65 | 66 | 67 | @pytest.fixture 68 | def response_kwargs(): 69 | return { 70 | "url": "http://example.com", 71 | "method": "GET", 72 | "body": None, 73 | "headers": {}, 74 | "client_response": mock.Mock(), 75 | } 76 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import os 3 | import re 4 | 5 | from setuptools import Command, find_packages, setup 6 | 7 | here = os.path.abspath(os.path.dirname(__file__)) 8 | 9 | version = "0.0.0" 10 | changes = os.path.join(here, "CHANGES.rst") 11 | match = r"^#*\s*(?P[0-9]+\.[0-9]+(\.[0-9]+)?)$" 12 | with codecs.open(changes, encoding="utf-8") as changes: 13 | for line in changes: 14 | res = re.match(match, line) 15 | if res: 16 | version = res.group("version") 17 | break 18 | 19 | # Get the long description 20 | with codecs.open(os.path.join(here, "README.rst"), encoding="utf-8") as f: 21 | long_description = f.read() 22 | 23 | # Get version 24 | with codecs.open(os.path.join(here, "CHANGES.rst"), encoding="utf-8") as f: 25 | changelog = f.read() 26 | 27 | 28 | install_requirements = ["python-status>=1.0.1", "httpx>=0.23.0", "python-slugify>=6.1.2"] 29 | tests_requirements = ["pytest", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "coveralls"] 30 | 31 | 32 | class VersionCommand(Command): 33 | description = "print library version" 34 | user_options = [] 35 | 36 | def initialize_options(self): 37 | pass 38 | 39 | def finalize_options(self): 40 | pass 41 | 42 | def run(self): 43 | print(version) 44 | 45 | 46 | setup( 47 | name="simple-rest-client", 48 | version=version, 49 | description="Simple REST client for python 3.8+", 50 | long_description=long_description, 51 | url="https://github.com/allisson/python-simple-rest-client", 52 | author="Allisson Azevedo", 53 | author_email="allisson@gmail.com", 54 | classifiers=[ 55 | "Development Status :: 3 - Alpha", 56 | "Intended Audience :: Developers", 57 | "Programming Language :: Python :: 3.8", 58 | "Programming Language :: Python :: 3.9", 59 | "Programming Language :: Python :: 3.10", 60 | "Programming Language :: Python :: 3.11", 61 | "Programming Language :: Python :: 3.12", 62 | "Topic :: Software Development :: Libraries", 63 | ], 64 | keywords="rest client http httpx asyncio", 65 | packages=find_packages(exclude=["docs", "tests*"]), 66 | setup_requires=["pytest-runner"], 67 | install_requires=install_requirements, 68 | tests_require=tests_requirements, 69 | cmdclass={"version": VersionCommand}, 70 | ) 71 | -------------------------------------------------------------------------------- /simple_rest_client/api.py: -------------------------------------------------------------------------------- 1 | from slugify import slugify 2 | 3 | from simple_rest_client.resource import Resource 4 | 5 | 6 | class API: 7 | def __init__( 8 | self, 9 | api_root_url=None, 10 | params=None, 11 | headers=None, 12 | timeout=None, 13 | append_slash=False, 14 | json_encode_body=False, 15 | ssl_verify=None, 16 | ): 17 | self.api_root_url = api_root_url 18 | self.params = params or {} 19 | self.headers = headers or {} 20 | self.timeout = timeout 21 | self.append_slash = append_slash 22 | self.json_encode_body = json_encode_body 23 | self.ssl_verify = True if ssl_verify is None else ssl_verify 24 | self._resources = {} 25 | 26 | def add_resource( 27 | self, 28 | api_root_url=None, 29 | resource_name=None, 30 | resource_class=None, 31 | params=None, 32 | headers=None, 33 | timeout=None, 34 | append_slash=None, 35 | json_encode_body=None, 36 | ssl_verify=None, 37 | ): 38 | resource_class = resource_class or Resource 39 | resource = resource_class( 40 | api_root_url=api_root_url if api_root_url is not None else self.api_root_url, 41 | resource_name=resource_name, 42 | params=params if params is not None else self.params, 43 | headers=headers if headers is not None else self.headers, 44 | timeout=timeout if timeout is not None else self.timeout, 45 | append_slash=append_slash if append_slash is not None else self.append_slash, 46 | json_encode_body=json_encode_body if json_encode_body is not None else self.json_encode_body, 47 | ssl_verify=ssl_verify if ssl_verify is not None else self.ssl_verify, 48 | ) 49 | self._resources[resource_name] = resource 50 | resource_valid_name = self.correct_attribute_name(resource_name) 51 | setattr(self, resource_valid_name, resource) 52 | 53 | def get_resource_list(self): 54 | return list(self._resources.keys()) 55 | 56 | def correct_attribute_name(self, name): 57 | slug_name = slugify(name) 58 | return slug_name.replace("-", "_") 59 | 60 | def close_client(self): 61 | for resource in self._resources.values(): 62 | resource.close_client() 63 | 64 | async def aclose_client(self): 65 | for resource in self._resources.values(): 66 | await resource.close_client() 67 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | --------- 3 | 4 | 1.2.1 5 | ~~~~~ 6 | 7 | * Add readthedocs config. 8 | 9 | 1.2.0 10 | ~~~~~ 11 | 12 | * Makefile refactor (thanks @pantuza). 13 | * Drop support for python 3.7.x. 14 | * Add a fix for stricter enforcement around client scoping. 15 | * Update base requirements version. 16 | 17 | 1.1.3 18 | ~~~~~ 19 | 20 | * Update pre-commit config and code format. 21 | * Remove broken examples. 22 | * Update base requirements version. 23 | 24 | 1.1.2 25 | ~~~~~ 26 | 27 | * Fix preserve resource_name as given (thanks @leorochael). 28 | 29 | 1.1.1 30 | ~~~~~ 31 | 32 | * Fix content-type is always json if json_encode_body is true (thanks @aenima-x). 33 | 34 | 1.1.0 35 | ~~~~~ 36 | 37 | * Drop support for python 3.6.x. 38 | * Fix json_encode_body cannot be overridden bug (thanks @denravonska). 39 | * Replace asynctest with asyncmock. 40 | * Update httpx and python-slugify to latest versions. 41 | * Update pre-commit rules. 42 | 43 | 1.0.8 44 | ~~~~~ 45 | 46 | * Use httpx.RequestError as base exception for ClientConnectionError. 47 | 48 | 1.0.7 49 | ~~~~~ 50 | 51 | * Update ClientConnectionError to deal with httpx.NetworkError exceptions (thanks @depauwjimmy). 52 | 53 | 1.0.6 54 | ~~~~~ 55 | 56 | * Fix httpx exception imports on httpx 0.13.x (thanks @denravonska). 57 | 58 | 1.0.5 59 | ~~~~~ 60 | 61 | * Fix imports on httpx 0.12.x (thanks @stekman37). 62 | 63 | 1.0.4 64 | ~~~~~ 65 | 66 | * Update httpx version (sync version is back yey!). 67 | * Use exceptions.TimeoutException for ClientConnectionError. 68 | 69 | 1.0.3 70 | ~~~~~ 71 | 72 | * Fix api resource names (thanks @cfytrok). 73 | 74 | 1.0.2 75 | ~~~~~ 76 | 77 | * Locking httpx version below 0.8.0 (thanks @patcon). 78 | 79 | 1.0.1 80 | ~~~~~ 81 | 82 | * Simplify httpx timeout handling (thanks @daneoshiga). 83 | 84 | 1.0.0 85 | ~~~~~ 86 | 87 | * Major release. 88 | * Use httpx instead of aiohttp and requests. 89 | * Drop python 3.5. 90 | 91 | 0.6.0 92 | ~~~~~ 93 | 94 | * Add support for disable certificate validation (thanks @rfrp). 95 | 96 | 0.5.4 97 | ~~~~~ 98 | 99 | * Prevent urls with double slashes. 100 | 101 | 0.5.3 102 | ~~~~~ 103 | 104 | * Fix api_root_url without trailing slash (thanks @dspechnikov). 105 | 106 | 0.5.2 107 | ~~~~~ 108 | 109 | * Fix JSONDecodeError when processing empty server responses (thanks @zmbbb). 110 | 111 | 0.5.1 112 | ~~~~~ 113 | 114 | * Change log level for async requests (thanks @kwarunek). 115 | 116 | 0.5.0 117 | ~~~~~ 118 | 119 | * Add new exceptions: AuthError and NotFoundError. 120 | 121 | 0.4.0 122 | ~~~~~ 123 | 124 | * Add request.kwargs support. 125 | 126 | 0.3.0 127 | ~~~~~ 128 | 129 | * Add client_response in Response object. 130 | 131 | 0.2.0 132 | ~~~~~ 133 | 134 | * Add asyncio support (aiohttp). 135 | 136 | 0.1.1 137 | ~~~~~ 138 | 139 | * Add MANIFEST.in (fix install by pip). 140 | 141 | 0.1.0 142 | ~~~~~ 143 | 144 | * Initial release. 145 | -------------------------------------------------------------------------------- /simple_rest_client/request.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from simple_rest_client.decorators import handle_async_request_error, handle_request_error 4 | from simple_rest_client.models import Response 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | @handle_request_error 10 | def make_request(client, request): 11 | logger.debug("operation=request_started, request=%r", request) 12 | method = request.method 13 | client_method = getattr(client, method.lower()) 14 | client_options = { 15 | "params": request.params, 16 | "headers": request.headers, 17 | "timeout": request.timeout, 18 | **request.kwargs, 19 | } 20 | if method.lower() in ("post", "put", "patch"): 21 | if request.headers.get("Content-Type") == "application/json": 22 | client_options["json"] = request.body 23 | else: 24 | client_options["data"] = request.body 25 | client_response = client_method(request.url, **client_options) 26 | content_type = client_response.headers.get("Content-Type", "") 27 | if "text" in content_type: 28 | body = client_response.text 29 | elif "json" in content_type: 30 | body = client_response.text 31 | if body: 32 | body = client_response.json() 33 | else: 34 | body = client_response.content 35 | 36 | response = Response( 37 | url=str(client_response.url), 38 | method=method, 39 | body=body, 40 | headers=client_response.headers, 41 | status_code=client_response.status_code, 42 | client_response=client_response, 43 | ) 44 | logger.debug("operation=request_finished, request=%r, response=%r", request, response) 45 | return response 46 | 47 | 48 | @handle_async_request_error 49 | async def make_async_request(client, request): 50 | logger.debug("operation=request_started, request=%r", request) 51 | method = request.method 52 | client_method = getattr(client, method.lower()) 53 | client_options = { 54 | "params": request.params, 55 | "headers": request.headers, 56 | "timeout": request.timeout, 57 | **request.kwargs, 58 | } 59 | if method.lower() in ("post", "put", "patch"): 60 | if request.headers.get("Content-Type") == "application/json": 61 | client_options["json"] = request.body 62 | else: 63 | client_options["data"] = request.body 64 | client_response = await client_method(request.url, **client_options) 65 | content_type = client_response.headers.get("Content-Type", "") 66 | if "text" in content_type: 67 | body = client_response.text 68 | elif "json" in content_type: 69 | body = client_response.text 70 | if body: 71 | body = client_response.json() 72 | else: 73 | body = client_response.content 74 | 75 | response = Response( 76 | url=str(client_response.url), 77 | method=method, 78 | body=body, 79 | headers=client_response.headers, 80 | status_code=client_response.status_code, 81 | client_response=client_response, 82 | ) 83 | logger.debug("operation=request_finished, request=%r, response=%r", request, response) 84 | return response 85 | -------------------------------------------------------------------------------- /tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import httpx 4 | import pytest 5 | import status 6 | from asyncmock import AsyncMock 7 | 8 | from simple_rest_client.decorators import handle_async_request_error, handle_request_error, validate_response 9 | from simple_rest_client.exceptions import ( 10 | AuthError, 11 | ClientConnectionError, 12 | ClientError, 13 | NotFoundError, 14 | ServerError, 15 | ) 16 | from simple_rest_client.models import Response 17 | 18 | 19 | @pytest.mark.parametrize("status_code", range(500, 506)) 20 | def test_validate_response_server_error(status_code, response_kwargs): 21 | response = Response(**response_kwargs, status_code=status_code) 22 | with pytest.raises(ServerError) as excinfo: 23 | validate_response(response) 24 | assert excinfo.value.response.status_code == status_code 25 | assert "operation=server_error" in str(excinfo.value) 26 | 27 | 28 | @pytest.mark.parametrize("status_code", (401, 403)) 29 | def test_validate_response_auth_error(status_code, response_kwargs): 30 | response = Response(**response_kwargs, status_code=status_code) 31 | with pytest.raises(AuthError) as excinfo: 32 | validate_response(response) 33 | assert excinfo.value.response.status_code == status_code 34 | assert "operation=auth_error" in str(excinfo.value) 35 | 36 | 37 | def test_validate_response_not_found_error(response_kwargs): 38 | status_code = status.HTTP_404_NOT_FOUND 39 | response = Response(**response_kwargs, status_code=status_code) 40 | with pytest.raises(NotFoundError) as excinfo: 41 | validate_response(response) 42 | assert excinfo.value.response.status_code == status_code 43 | assert "operation=not_found_error" in str(excinfo.value) 44 | 45 | 46 | @pytest.mark.parametrize("status_code", range(405, 417)) 47 | def test_validate_response_client_error(status_code, response_kwargs): 48 | response = Response(**response_kwargs, status_code=status_code) 49 | with pytest.raises(ClientError) as excinfo: 50 | validate_response(response) 51 | assert excinfo.value.response.status_code == status_code 52 | assert "operation=client_error" in str(excinfo.value) 53 | 54 | 55 | @pytest.mark.parametrize( 56 | "side_effect", 57 | ( 58 | httpx.RequestError, 59 | httpx.ConnectTimeout, 60 | httpx.ReadTimeout, 61 | httpx.WriteTimeout, 62 | httpx.PoolTimeout, 63 | httpx.NetworkError, 64 | httpx.ProxyError, 65 | ), 66 | ) 67 | def test_handle_request_error_exceptions(side_effect): 68 | wrapped = mock.Mock(side_effect=side_effect(message="message", request="Request")) 69 | with pytest.raises(ClientConnectionError): 70 | handle_request_error(wrapped)() 71 | 72 | 73 | @pytest.mark.parametrize( 74 | "side_effect", 75 | ( 76 | httpx.RequestError, 77 | httpx.ConnectTimeout, 78 | httpx.ReadTimeout, 79 | httpx.WriteTimeout, 80 | httpx.PoolTimeout, 81 | httpx.NetworkError, 82 | httpx.ProxyError, 83 | ), 84 | ) 85 | def test_handle_async_request_error_exceptions(event_loop, side_effect): 86 | wrapped = AsyncMock(side_effect=side_effect(message="message", request="Request")) 87 | with pytest.raises(ClientConnectionError): 88 | event_loop.run_until_complete(handle_async_request_error(wrapped)()) 89 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from simple_rest_client.api import API 4 | from simple_rest_client.resource import Resource 5 | 6 | 7 | def test_api_headers(): 8 | api = API(api_root_url="http://localhost:0/api/") 9 | assert api.headers == {} 10 | 11 | json_api = API(api_root_url="http://localhost:0/api/", headers={"Content-Type": "application/json"}) 12 | assert json_api.headers == {"Content-Type": "application/json"} 13 | 14 | 15 | @pytest.mark.parametrize("ssl_verify,expected_ssl_verify", [(None, True), (True, True), (False, False)]) 16 | def test_api_ssl_verify(ssl_verify, expected_ssl_verify, api, reqres_resource): 17 | api = API(api_root_url="http://localhost:0/api/", json_encode_body=True, ssl_verify=ssl_verify) 18 | api.add_resource(resource_name="users") 19 | assert api.ssl_verify == expected_ssl_verify 20 | 21 | 22 | def test_api_add_resource(api, reqres_resource): 23 | api.add_resource(resource_name="users") 24 | assert isinstance(api.users, Resource) 25 | attrs = ( 26 | "actions", 27 | "api_root_url", 28 | "append_slash", 29 | "headers", 30 | "json_encode_body", 31 | "resource_name", 32 | "ssl_verify", 33 | "timeout", 34 | ) 35 | for attr in attrs: 36 | assert getattr(api.users, attr) == getattr(reqres_resource, attr) 37 | assert "users" in api._resources 38 | 39 | 40 | @pytest.mark.parametrize( 41 | "resource_name,resource_valid_name", 42 | [("users", "users"), ("my-users", "my_users"), ("my users", "my_users"), ("影師嗎", "ying_shi_ma")], 43 | ) 44 | def test_api_resource_valid_name(resource_name, resource_valid_name, api): 45 | api.add_resource(resource_name=resource_name) 46 | resource = getattr(api, resource_valid_name) 47 | assert isinstance(resource, Resource) 48 | assert resource_name in api._resources 49 | assert resource.get_action_full_url("list") == f"{api.api_root_url}{resource_name}" 50 | 51 | 52 | def test_api_add_resource_with_other_resource_class(api, reqres_resource): 53 | class AnotherResource(Resource): 54 | def extra_action(self): 55 | return True 56 | 57 | api.add_resource(resource_name="users", resource_class=AnotherResource) 58 | assert api.users.extra_action() 59 | 60 | 61 | def test_api_get_resource_list(api): 62 | api.add_resource(resource_name="users") 63 | api.add_resource(resource_name="login") 64 | resource_list = api.get_resource_list() 65 | assert "users" in resource_list 66 | assert "login" in resource_list 67 | 68 | 69 | @pytest.mark.parametrize( 70 | "url,method,status,action,args,kwargs", 71 | [ 72 | ("/api/users", "GET", 200, "list", None, {}), 73 | ("/api/users", "POST", 201, "create", None, {"body": {"success": True}}), 74 | ("/api/users/2", "GET", 200, "retrieve", 2, {"body": {"success": True}}), 75 | ("/api/users/2", "PUT", 200, "update", 2, {"body": {"success": True}}), 76 | ("/api/users/2", "PATCH", 200, "partial_update", 2, {"body": {"success": True}}), 77 | ("/api/users/2", "DELETE", 204, "destroy", 2, {"body": {"success": True}}), 78 | ], 79 | ) 80 | def test_reqres_api_users_actions(httpserver, url, method, status, action, args, kwargs, reqres_api): 81 | httpserver.expect_request(url, method=method).respond_with_json({"success": True}, status=status) 82 | 83 | response = getattr(reqres_api.users, action)(args, **kwargs) 84 | assert response.status_code == status 85 | assert response.method == method 86 | assert url in response.url 87 | if method != "DELETE": 88 | assert response.body == {"success": True} 89 | 90 | 91 | @pytest.mark.asyncio 92 | @pytest.mark.parametrize( 93 | "url,method,status,action,args,kwargs", 94 | [ 95 | ("/api/users", "GET", 200, "list", None, {}), 96 | ("/api/users", "POST", 201, "create", None, {"body": {"success": True}}), 97 | ("/api/users/2", "GET", 200, "retrieve", 2, {"body": {"success": True}}), 98 | ("/api/users/2", "PUT", 200, "update", 2, {"body": {"success": True}}), 99 | ("/api/users/2", "PATCH", 200, "partial_update", 2, {"body": {"success": True}}), 100 | ("/api/users/2", "DELETE", 204, "destroy", 2, {"body": {"success": True}}), 101 | ], 102 | ) 103 | async def test_reqres_async_api_users_actions( 104 | httpserver, url, method, status, action, args, kwargs, reqres_async_api 105 | ): 106 | httpserver.expect_request(url, method=method).respond_with_json({"success": True}, status=status) 107 | 108 | response = await getattr(reqres_async_api.users, action)(args, **kwargs) 109 | assert response.status_code == status 110 | assert response.method == method 111 | assert url in response.url 112 | if method != "DELETE": 113 | assert response.body == {"success": True} 114 | -------------------------------------------------------------------------------- /simple_rest_client/resource.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from types import MethodType 3 | 4 | import httpx 5 | 6 | from simple_rest_client.exceptions import ActionNotFound, ActionURLMatchError 7 | from simple_rest_client.models import Request 8 | from simple_rest_client.request import make_async_request, make_request 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class BaseResource: 14 | actions = {} 15 | 16 | def __init__( 17 | self, 18 | api_root_url=None, 19 | resource_name=None, 20 | params=None, 21 | headers=None, 22 | timeout=None, 23 | append_slash=False, 24 | json_encode_body=False, 25 | ssl_verify=None, 26 | ): 27 | self.api_root_url = api_root_url 28 | self.resource_name = resource_name 29 | self.params = params or {} 30 | self.headers = headers or {} 31 | self.timeout = timeout or 3 32 | self.append_slash = append_slash 33 | self.json_encode_body = json_encode_body 34 | self.actions = self.actions or self.default_actions 35 | self.ssl_verify = True if ssl_verify is None else ssl_verify 36 | 37 | if self.json_encode_body: 38 | self.headers["Content-Type"] = "application/json" 39 | 40 | @property 41 | def default_actions(self): 42 | return { 43 | "list": {"method": "GET", "url": self.resource_name}, 44 | "create": {"method": "POST", "url": self.resource_name}, 45 | "retrieve": {"method": "GET", "url": self.resource_name + "/{}"}, 46 | "update": {"method": "PUT", "url": self.resource_name + "/{}"}, 47 | "partial_update": {"method": "PATCH", "url": self.resource_name + "/{}"}, 48 | "destroy": {"method": "DELETE", "url": self.resource_name + "/{}"}, 49 | } 50 | 51 | def get_action(self, action_name): 52 | try: 53 | return self.actions[action_name] 54 | except KeyError: 55 | raise ActionNotFound('action "{}" not found'.format(action_name)) 56 | 57 | def get_action_full_url(self, action_name, *parts): 58 | action = self.get_action(action_name) 59 | try: 60 | url = action["url"].format(*parts) 61 | except IndexError: 62 | raise ActionURLMatchError('No url match for "{}"'.format(action_name)) 63 | 64 | if self.append_slash and not url.endswith("/"): 65 | url += "/" 66 | if not self.api_root_url.endswith("/"): 67 | self.api_root_url += "/" 68 | if url.startswith("/"): 69 | url = url.replace("/", "", 1) 70 | return self.api_root_url + url 71 | 72 | def get_action_method(self, action_name): 73 | action = self.get_action(action_name) 74 | return action["method"] 75 | 76 | 77 | class Resource(BaseResource): 78 | def __init__(self, *args, **kwargs): 79 | super().__init__(*args, **kwargs) 80 | self.client = httpx.Client(verify=self.ssl_verify) 81 | for action_name in self.actions.keys(): 82 | self.add_action(action_name) 83 | 84 | def close_client(self): 85 | self.client.close() 86 | 87 | def add_action(self, action_name): 88 | def action_method( 89 | self, *args, body=None, params=None, headers=None, action_name=action_name, **kwargs 90 | ): 91 | url = self.get_action_full_url(action_name, *args) 92 | method = self.get_action_method(action_name) 93 | request = Request( 94 | url=url, 95 | method=method, 96 | params=params or {}, 97 | body=body, 98 | headers=headers or {}, 99 | timeout=self.timeout, 100 | kwargs=kwargs, 101 | ) 102 | request.params.update(self.params) 103 | request.headers.update(self.headers) 104 | return make_request(self.client, request) 105 | 106 | setattr(self, action_name, MethodType(action_method, self)) 107 | 108 | 109 | class AsyncResource(BaseResource): 110 | def __init__(self, *args, **kwargs): 111 | super().__init__(*args, **kwargs) 112 | self.client = httpx.AsyncClient(verify=self.ssl_verify) 113 | for action_name in self.actions.keys(): 114 | self.add_action(action_name) 115 | 116 | async def close_client(self): 117 | await self.client.aclose() 118 | 119 | def add_action(self, action_name): 120 | async def action_method( 121 | self, *args, body=None, params=None, headers=None, action_name=action_name, **kwargs 122 | ): 123 | url = self.get_action_full_url(action_name, *args) 124 | method = self.get_action_method(action_name) 125 | request = Request( 126 | url=url, 127 | method=method, 128 | params=params or {}, 129 | body=body, 130 | headers=headers or {}, 131 | timeout=self.timeout, 132 | kwargs=kwargs, 133 | ) 134 | request.params.update(self.params) 135 | request.headers.update(self.headers) 136 | return await make_async_request(self.client, request) 137 | 138 | setattr(self, action_name, MethodType(action_method, self)) 139 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # python-simple-rest-client documentation build configuration file, created by 5 | # sphinx-quickstart on Sat Apr 15 17:57:28 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # 29 | # needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ["_templates"] 38 | 39 | # The suffix(es) of source filenames. 40 | # You can specify multiple suffix as a list of string: 41 | # 42 | # source_suffix = ['.rst', '.md'] 43 | source_suffix = ".rst" 44 | 45 | # The master toctree document. 46 | master_doc = "index" 47 | 48 | # General information about the project. 49 | project = "python-simple-rest-client" 50 | copyright = "2017, Allisson Azevedo" 51 | author = "Allisson Azevedo" 52 | 53 | # The version info for the project you're documenting, acts as replacement for 54 | # |version| and |release|, also used in various other places throughout the 55 | # built documents. 56 | # 57 | # The short X.Y version. 58 | version = "0.1" 59 | # The full version, including alpha/beta/rc tags. 60 | release = "0.1.0" 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | # 65 | # This is also used if you do content translation via gettext catalogs. 66 | # Usually you set "language" from the command line for these cases. 67 | language = None 68 | 69 | # List of patterns, relative to source directory, that match files and 70 | # directories to ignore when looking for source files. 71 | # This patterns also effect to html_static_path and html_extra_path 72 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 73 | 74 | # The name of the Pygments (syntax highlighting) style to use. 75 | pygments_style = "sphinx" 76 | 77 | # If true, `todo` and `todoList` produce output, else they produce nothing. 78 | todo_include_todos = False 79 | 80 | 81 | # -- Options for HTML output ---------------------------------------------- 82 | 83 | # The theme to use for HTML and HTML Help pages. See the documentation for 84 | # a list of builtin themes. 85 | # 86 | html_theme = "default" 87 | 88 | # Theme options are theme-specific and customize the look and feel of a theme 89 | # further. For a list of options available for each theme, see the 90 | # documentation. 91 | # 92 | # html_theme_options = {} 93 | 94 | # Add any paths that contain custom static files (such as style sheets) here, 95 | # relative to this directory. They are copied after the builtin static files, 96 | # so a file named "default.css" will overwrite the builtin "default.css". 97 | html_static_path = ["_static"] 98 | 99 | 100 | # -- Options for HTMLHelp output ------------------------------------------ 101 | 102 | # Output file base name for HTML help builder. 103 | htmlhelp_basename = "python-simple-rest-clientdoc" 104 | 105 | 106 | # -- Options for LaTeX output --------------------------------------------- 107 | 108 | latex_elements = { 109 | # The paper size ('letterpaper' or 'a4paper'). 110 | # 111 | # 'papersize': 'letterpaper', 112 | # The font size ('10pt', '11pt' or '12pt'). 113 | # 114 | # 'pointsize': '10pt', 115 | # Additional stuff for the LaTeX preamble. 116 | # 117 | # 'preamble': '', 118 | # Latex figure (float) alignment 119 | # 120 | # 'figure_align': 'htbp', 121 | } 122 | 123 | # Grouping the document tree into LaTeX files. List of tuples 124 | # (source start file, target name, title, 125 | # author, documentclass [howto, manual, or own class]). 126 | latex_documents = [ 127 | ( 128 | master_doc, 129 | "python-simple-rest-client.tex", 130 | "python-simple-rest-client Documentation", 131 | "Allisson Azevedo", 132 | "manual", 133 | ) 134 | ] 135 | 136 | 137 | # -- Options for manual page output --------------------------------------- 138 | 139 | # One entry per manual page. List of tuples 140 | # (source start file, name, description, authors, manual section). 141 | man_pages = [ 142 | (master_doc, "python-simple-rest-client", "python-simple-rest-client Documentation", [author], 1) 143 | ] 144 | 145 | 146 | # -- Options for Texinfo output ------------------------------------------- 147 | 148 | # Grouping the document tree into Texinfo files. List of tuples 149 | # (source start file, target name, title, author, 150 | # dir menu entry, description, category) 151 | texinfo_documents = [ 152 | ( 153 | master_doc, 154 | "python-simple-rest-client", 155 | "python-simple-rest-client Documentation", 156 | author, 157 | "python-simple-rest-client", 158 | "One line description of project.", 159 | "Miscellaneous", 160 | ) 161 | ] 162 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ========== 3 | 4 | Let's start building a client for users resource in https://reqres.in/ service:: 5 | 6 | >>> # import API 7 | >>> from simple_rest_client.api import API 8 | >>> # create api instance 9 | >>> api = API( 10 | ... api_root_url='https://reqres.in/api/', # base api url 11 | ... params={}, # default params 12 | ... headers={}, # default headers 13 | ... timeout=2, # default timeout in seconds 14 | ... append_slash=False, # append slash to final url 15 | ... json_encode_body=True, # encode body as json 16 | ... ) 17 | >>> # add users resource 18 | >>> api.add_resource(resource_name='users') 19 | >>> # show resource actions 20 | >>> api.users.actions 21 | {'list': {'method': 'GET', 'url': 'users'}, 'create': {'method': 'POST', 'url': 'users'}, 'retrieve': {'method': 'GET', 'url': 'users/{}'}, 'update': {'method': 'PUT', 'url': 'users/{}'}, 'partial_update': {'method': 'PATCH', 'url': 'users/{}'}, 'destroy': {'method': 'DELETE', 'url': 'users/{}'}} 22 | >>> # list action 23 | >>> response = api.users.list(body=None, params={}, headers={}) 24 | >>> response.url 25 | 'https://reqres.in/api/users' 26 | >>> response.method 27 | 'GET' 28 | >>> response.body 29 | {'page': 1, 'per_page': 3, 'total': 12, 'total_pages': 4, 'data': [{'id': 1, 'first_name': 'george', 'last_name': 'bluth', 'avatar': 'https://s3.amazonaws.com/uifaces/faces/twitter/calebogden/128.jpg'}, {'id': 2, 'first_name': 'lucille', 'last_name': 'bluth', 'avatar': 'https://s3.amazonaws.com/uifaces/faces/twitter/josephstein/128.jpg'}, {'id': 3, 'first_name': 'oscar', 'last_name': 'bluth', 'avatar': 'https://s3.amazonaws.com/uifaces/faces/twitter/olegpogodaev/128.jpg'}]} 30 | >>> response.headers 31 | {'Date': 'Sat, 15 Apr 2017 21:39:46 GMT', 'Content-Type': 'application/json; charset=utf-8', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', 'X-Powered-By': 'Express', 'Access-Control-Allow-Origin': '*', 'ETag': 'W/"1be-q96WkDv6JqfLvIPiRhzWJQ"', 'Server': 'cloudflare-nginx', 'CF-RAY': '35020f33aaf04a9c-GRU', 'Content-Encoding': 'gzip'} 32 | >>> response.client_response.cookies 33 | 34 | 35 | >>> response.status_code 36 | 200 37 | >>> # create action 38 | >>> body = {'name': 'morpheus', 'job': 'leader'} 39 | >>> response = api.users.create(body=body, params={}, headers={}) 40 | >>> response.status_code 41 | 201 42 | >>> # retrieve action 43 | >>> response = api.users.retrieve(2, body=None, params={}, headers={}) 44 | >>> response.status_code 45 | 200 46 | >>> # update action 47 | >>> response = api.users.update(2, body=body, params={}, headers={}) 48 | >>> response.status_code 49 | 200 50 | >>> # partial update action 51 | >>> response = api.users.partial_update(2, body=body, params={}, headers={}) 52 | >>> response.status_code 53 | 200 54 | >>> # destroy action 55 | >>> response = api.users.destroy(2, body=None, params={}, headers={}) 56 | >>> response.status_code 57 | 204 58 | >>> # close client connections 59 | >>> api.close_client() 60 | 61 | Building async client for users resource in https://reqres.in/ service:: 62 | 63 | >>> # import asyncio, API and AsyncResource 64 | >>> import asyncio 65 | >>> from simple_rest_client.api import API 66 | >>> from simple_rest_client.resource import AsyncResource 67 | >>> # create api instance 68 | >>> api = API( 69 | ... api_root_url='https://reqres.in/api/', # base api url 70 | ... params={}, # default params 71 | ... headers={}, # default headers 72 | ... timeout=2, # default timeout in seconds 73 | ... append_slash=False, # append slash to final url 74 | ... json_encode_body=True, # encode body as json 75 | ... ) 76 | >>> # add users resource 77 | >>> api.add_resource(resource_name='users', resource_class=AsyncResource) 78 | >>> async def main(): 79 | ... print(await api.users.list()) 80 | ... # close client connections 81 | ... await api.aclose_client() 82 | ... 83 | >>> loop = asyncio.get_event_loop() 84 | >>> loop.run_until_complete(main()) 85 | Response(url='https://reqres.in/api/users', method='GET', body={'page': 1, 'per_page': 3, 'total': 12, 'total_pages': 4, 'data': [{'id': 1, 'first_name': 'george', 'last_name': 'bluth', 'avatar': 'https://s3.amazonaws.com/uifaces/faces/twitter/calebogden/128.jpg'}, {'id': 2, 'first_name': 'lucille', 'last_name': 'bluth', 'avatar': 'https://s3.amazonaws.com/uifaces/faces/twitter/josephstein/128.jpg'}, {'id': 3, 'first_name': 'oscar', 'last_name': 'bluth', 'avatar': 'https://s3.amazonaws.com/uifaces/faces/twitter/olegpogodaev/128.jpg'}]}, headers={'Date': 'Mon, 26 Jun 2017 19:03:04 GMT', 'Content-Type': 'application/json; charset=utf-8', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', 'Set-Cookie': '__cfduid=d0412e4ebb0c5c29b09c0f756408d6ccb1498503784; expires=Tue, 26-Jun-18 19:03:04 GMT; path=/; domain=.reqres.in; HttpOnly', 'X-Powered-By': 'Express', 'Access-Control-Allow-Origin': '*', 'ETag': 'W/"1be-q96WkDv6JqfLvIPiRhzWJQ"', 'Server': 'cloudflare-nginx', 'CF-RAY': '37526caddd214af1-GRU', 'Content-Encoding': 'gzip'}, status_code=200) 86 | 87 | 88 | Now, building a client for github events resource (https://developer.github.com/v3/activity/events/):: 89 | 90 | >>> # import API and Resource 91 | >>> from simple_rest_client.api import API 92 | >>> from simple_rest_client.resource import Resource 93 | >>> # create EventResource with custom actions 94 | >>> class EventResource(Resource): 95 | ... actions = { 96 | ... 'public_events': {'method': 'GET', 'url': 'events'}, 97 | ... 'repository_events': {'method': 'GET', 'url': '/repos/{}/{}/events'}, 98 | ... 'repository_issues_events': {'method': 'GET', 'url': '/repos/{}/{}/issues/events'}, 99 | ... 'public_network_events': {'method': 'GET', 'url': '/networks/{}/{}/events'}, 100 | ... 'public_organization_events': {'method': 'GET', 'url': '/orgs/{}/events'}, 101 | ... 'user_received_events': {'method': 'GET', 'url': '/users/{}/received_events'}, 102 | ... 'public_user_received_events': {'method': 'GET', 'url': '/users/{}/received_events/public'}, 103 | ... 'user_events': {'method': 'GET', 'url': '/users/{}/events'}, 104 | ... 'public_user_events': {'method': 'GET', 'url': '/users/{}/events/public'}, 105 | ... 'organization_events': {'method': 'GET', 'url': '/users/{}/events/orgs/{}'}, 106 | ... } 107 | ... 108 | >>> # set default params 109 | >>> default_params = {'access_token': 'valid-token'} 110 | >>> # create api instance 111 | >>> github_api = API( 112 | ... api_root_url='https://api.github.com', params=default_params, 113 | ... json_encode_body=True 114 | ... ) 115 | >>> # add events resource with EventResource 116 | >>> github_api.add_resource(resource_name='events', resource_class=EventResource) 117 | >>> # show resource actions 118 | >>> github_api.events.actions 119 | {'public_events': {'method': 'GET', 'url': 'events'}, 'repository_events': {'method': 'GET', 'url': '/repos/{}/{}/events'}, 'repository_issues_events': {'method': 'GET', 'url': '/repos/{}/{}/issues/events'}, 'public_network_events': {'method': 'GET', 'url': '/networks/{}/{}/events'}, 'public_organization_events': {'method': 'GET', 'url': '/orgs/{}/events'}, 'user_received_events': {'method': 'GET', 'url': '/users/{}/received_events'}, 'public_user_received_events': {'method': 'GET', 'url': '/users/{}/received_events/public'}, 'user_events': {'method': 'GET', 'url': '/users/{}/events'}, 'public_user_events': {'method': 'GET', 'url': '/users/{}/events/public'}, 'organization_events': {'method': 'GET', 'url': '/users/{}/events/orgs/{}'}} 120 | >>> # public_events action 121 | >>> response = github_api.events.public_events(body=None, params={}, headers={}) 122 | >>> response.url 123 | 'https://api.github.com/events?access_token=valid-token' 124 | >>> response.method 125 | 'GET' 126 | >>> # repository_events action 127 | >>> response = github_api.events.repository_events('allisson', 'python-simple-rest-client', body=None, params={}, headers={}) 128 | >>> response.url 129 | 'https://api.github.com/repos/allisson/python-simple-rest-client/events?access_token=valid-token' 130 | >>> response.method 131 | 'GET' 132 | >>> # close client connections 133 | >>> api.close_client() 134 | 135 | Create API without certificate validation 136 | 137 | >>> # import API 138 | >>> from simple_rest_client.api import API 139 | >>> # create api instance 140 | >>> api = API( 141 | ... api_root_url='https://develop-environment-with-self-signed-certificate.in/api/', # base api url 142 | ... params={}, # default params 143 | ... headers={}, # default headers 144 | ... timeout=2, # default timeout in seconds 145 | ... append_slash=False, # append slash to final url 146 | ... json_encode_body=True, # encode body as json 147 | ... ssl_verify=False # ignore verifying the ssl 148 | ... ) 149 | 150 | Check `https://github.com/allisson/python-simple-rest-client/tree/master/examples `_ for more code examples. 151 | -------------------------------------------------------------------------------- /tests/test_resource.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from simple_rest_client.exceptions import ActionNotFound, ActionURLMatchError 4 | 5 | 6 | def test_base_resource_headers(base_resource): 7 | resource = base_resource(api_root_url="http://example.com", resource_name="users") 8 | assert resource.headers == {} 9 | 10 | json_resource = base_resource( 11 | api_root_url="http://example.com", resource_name="users", json_encode_body=True 12 | ) 13 | assert json_resource.headers == {"Content-Type": "application/json"} 14 | 15 | 16 | @pytest.mark.parametrize("ssl_verify,expected_ssl_verify", [(None, True), (True, True), (False, False)]) 17 | def test_base_resource_ssl_verify(ssl_verify, expected_ssl_verify, base_resource): 18 | resource = base_resource(api_root_url="http://example.com", resource_name="users", ssl_verify=ssl_verify) 19 | assert resource.ssl_verify == expected_ssl_verify 20 | 21 | 22 | def test_base_resource_actions(base_resource): 23 | resource = base_resource(api_root_url="http://example.com", resource_name="users") 24 | assert resource.actions == resource.default_actions 25 | 26 | 27 | def test_base_resource_get_action_full_url(base_resource): 28 | resource = base_resource(api_root_url="http://example.com", resource_name="users") 29 | assert resource.get_action_full_url("list") == "http://example.com/users" 30 | assert resource.get_action_full_url("create") == "http://example.com/users" 31 | assert resource.get_action_full_url("retrieve", 1) == "http://example.com/users/1" 32 | assert resource.get_action_full_url("update", 1) == "http://example.com/users/1" 33 | assert resource.get_action_full_url("partial_update", 1) == "http://example.com/users/1" 34 | assert resource.get_action_full_url("destroy", 1) == "http://example.com/users/1" 35 | 36 | 37 | def test_base_resource_get_action_full_url_with_append_slash(base_resource): 38 | resource = base_resource(api_root_url="http://example.com", resource_name="users", append_slash=True) 39 | assert resource.get_action_full_url("list") == "http://example.com/users/" 40 | assert resource.get_action_full_url("create") == "http://example.com/users/" 41 | assert resource.get_action_full_url("retrieve", 1) == "http://example.com/users/1/" 42 | assert resource.get_action_full_url("update", 1) == "http://example.com/users/1/" 43 | assert resource.get_action_full_url("partial_update", 1) == "http://example.com/users/1/" 44 | assert resource.get_action_full_url("destroy", 1) == "http://example.com/users/1/" 45 | 46 | 47 | def test_base_resource_get_action_full_url_api_root_url_without_trailing_slash(base_resource): 48 | resource = base_resource(api_root_url="http://example.com/v1", resource_name="users") 49 | assert resource.get_action_full_url("list") == "http://example.com/v1/users" 50 | assert resource.get_action_full_url("create") == "http://example.com/v1/users" 51 | assert resource.get_action_full_url("retrieve", 1) == "http://example.com/v1/users/1" 52 | assert resource.get_action_full_url("update", 1) == "http://example.com/v1/users/1" 53 | assert resource.get_action_full_url("partial_update", 1) == "http://example.com/v1/users/1" 54 | assert resource.get_action_full_url("destroy", 1) == "http://example.com/v1/users/1" 55 | 56 | 57 | def test_base_resource_get_action_full_url_api_root_url_prevent_double_slash(base_resource): 58 | resource = base_resource(api_root_url="http://example.com/v1", resource_name="users") 59 | resource.actions = { 60 | "list": {"method": "GET", "url": "/users"}, 61 | "retrieve": {"method": "GET", "url": "/users/{}"}, 62 | } 63 | assert resource.get_action_full_url("list") == "http://example.com/v1/users" 64 | assert resource.get_action_full_url("retrieve", 1) == "http://example.com/v1/users/1" 65 | 66 | 67 | def test_base_resource_get_action_full_url_with_action_not_found(base_resource): 68 | resource = base_resource(api_root_url="http://example.com", resource_name="users") 69 | with pytest.raises(ActionNotFound) as execinfo: 70 | resource.get_action_full_url("notfoundaction") 71 | assert 'action "notfoundaction" not found' in str(execinfo.value) 72 | 73 | 74 | def test_base_resource_get_action_full_url_with_action_url_match_error(base_resource): 75 | resource = base_resource(api_root_url="http://example.com", resource_name="users") 76 | with pytest.raises(ActionURLMatchError) as execinfo: 77 | resource.get_action_full_url("retrieve") 78 | assert 'No url match for "retrieve"' in str(execinfo.value) 79 | 80 | 81 | def test_custom_resource_actions(custom_resource, actions): 82 | resource = custom_resource(api_root_url="http://example.com", resource_name="users") 83 | assert resource.actions == actions 84 | 85 | 86 | def test_custom_resource_get_action_full_url(custom_resource): 87 | resource = custom_resource(api_root_url="http://example.com", resource_name="users") 88 | assert resource.get_action_full_url("list", 1) == "http://example.com/1/users" 89 | assert resource.get_action_full_url("create", 1) == "http://example.com/1/users" 90 | assert resource.get_action_full_url("retrieve", 1, 2) == "http://example.com/1/users/2" 91 | assert resource.get_action_full_url("update", 1, 2) == "http://example.com/1/users/2" 92 | assert resource.get_action_full_url("partial_update", 1, 2) == "http://example.com/1/users/2" 93 | assert resource.get_action_full_url("destroy", 1, 2) == "http://example.com/1/users/2" 94 | 95 | 96 | @pytest.mark.parametrize( 97 | "url,method,status,action,args,kwargs", 98 | [ 99 | ("/api/users", "GET", 200, "list", None, {}), 100 | ("/api/users", "POST", 201, "create", None, {"body": {"success": True}}), 101 | ("/api/users/2", "GET", 200, "retrieve", 2, {"body": {"success": True}}), 102 | ("/api/users/2", "PUT", 200, "update", 2, {"body": {"success": True}}), 103 | ("/api/users/2", "PATCH", 200, "partial_update", 2, {"body": {"success": True}}), 104 | ("/api/users/2", "DELETE", 204, "destroy", 2, {"body": {"success": True}}), 105 | ], 106 | ) 107 | def test_resource_actions(httpserver, url, method, status, action, args, kwargs, reqres_resource): 108 | httpserver.expect_request(url, method=method).respond_with_json({"success": True}, status=status) 109 | 110 | response = getattr(reqres_resource, action)(args, **kwargs) 111 | assert response.status_code == status 112 | assert response.method == method 113 | assert url in response.url 114 | if method != "DELETE": 115 | assert response.body == {"success": True} 116 | 117 | 118 | @pytest.mark.parametrize( 119 | "content_type,response_body,expected_response_body", 120 | [ 121 | ("application/json", '{"success": true}', {"success": True}), 122 | ("application/json", "", ""), 123 | ("text/plain", '{"success": true}', '{"success": true}'), 124 | ("application/octet-stream", '{"success": true}', b'{"success": true}'), 125 | ], 126 | ) 127 | def test_resource_response_body( 128 | httpserver, content_type, response_body, expected_response_body, reqres_resource 129 | ): 130 | url = "/api/users" 131 | httpserver.expect_request(url, method="GET").respond_with_data( 132 | response_body, status=200, content_type=content_type 133 | ) 134 | 135 | response = reqres_resource.list() 136 | assert response.body == expected_response_body 137 | 138 | # call again to validate the fix for "Stricter enforcement around client scoping" 139 | response = reqres_resource.list() 140 | assert response.body == expected_response_body 141 | 142 | 143 | @pytest.mark.asyncio 144 | @pytest.mark.parametrize( 145 | "url,method,status,action,args,kwargs", 146 | [ 147 | ("/api/users", "GET", 200, "list", None, {}), 148 | ("/api/users", "POST", 201, "create", None, {"body": {"success": True}}), 149 | ("/api/users/2", "GET", 200, "retrieve", 2, {"body": {"success": True}}), 150 | ("/api/users/2", "PUT", 200, "update", 2, {"body": {"success": True}}), 151 | ("/api/users/2", "PATCH", 200, "partial_update", 2, {"body": {"success": True}}), 152 | ("/api/users/2", "DELETE", 204, "destroy", 2, {"body": {"success": True}}), 153 | ], 154 | ) 155 | async def test_async_resource_actions( 156 | httpserver, url, method, status, action, args, kwargs, reqres_async_resource 157 | ): 158 | httpserver.expect_request(url, method=method).respond_with_json({"success": True}, status=status) 159 | 160 | response = await getattr(reqres_async_resource, action)(args, **kwargs) 161 | assert response.status_code == status 162 | assert response.method == method 163 | assert url in response.url 164 | if method != "DELETE": 165 | assert response.body == {"success": True} 166 | 167 | 168 | @pytest.mark.asyncio 169 | @pytest.mark.parametrize( 170 | "content_type,response_body,expected_response_body", 171 | [ 172 | ("application/json", '{"success": true}', {"success": True}), 173 | ("application/json", "", ""), 174 | ("text/plain", '{"success": true}', '{"success": true}'), 175 | ("application/octet-stream", '{"success": true}', b'{"success": true}'), 176 | ], 177 | ) 178 | async def test_asyncresource_response_body( 179 | httpserver, content_type, response_body, expected_response_body, reqres_async_resource 180 | ): 181 | url = "/api/users" 182 | httpserver.expect_request(url, method="GET").respond_with_data( 183 | response_body, status=200, content_type=content_type 184 | ) 185 | 186 | response = await reqres_async_resource.list() 187 | assert response.body == expected_response_body 188 | 189 | # call again to validate the fix for "Stricter enforcement around client scoping" 190 | response = await reqres_async_resource.list() 191 | assert response.body == expected_response_body 192 | --------------------------------------------------------------------------------