├── .gitignore ├── .travis.yml ├── CHANGES ├── CREDITS ├── README.md ├── requirements.txt ├── requirements ├── requirements-development.txt └── requirements-testing.txt ├── rest_framework_proxy ├── __init__.py ├── adapters.py ├── models.py ├── settings.py ├── utils.py └── views.py ├── runtests.py ├── setup.py ├── tests ├── __init__.py ├── utils_test.py └── views_tests.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Mr Developer 33 | .mr.developer.cfg 34 | .project 35 | .pydevproject 36 | 37 | # Complexity 38 | output/*.html 39 | output/*/index.html 40 | 41 | # Sphinx 42 | docs/_build 43 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "2.7" 5 | - "3.4" 6 | - "3.5" 7 | 8 | sudo: false 9 | 10 | env: 11 | - Django=1.8 12 | - Django=1.9 13 | - Django=1.10 14 | - Django=1.11 15 | 16 | matrix: 17 | fast_finish: true 18 | include: 19 | # Django 1.11 is the first version to officially support Python 3.6 20 | - python: "3.6" 21 | env: DJANGO=1.11 22 | - python: "3.3" 23 | env: DJANGO=1.8 24 | 25 | install: 26 | - pip install tox tox-travis 27 | 28 | script: 29 | - tox 30 | 31 | after_success: 32 | - pip install codecov 33 | - codecov -e TOX_ENV;DJANGO 34 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | 1.6.0 2 | - Now requires DRF version 3.1 or newer. 3 | - Updated Travis CI conf run tests using tox 4 | 5 | 1.5.0 6 | ----- 7 | - Added the ability to place cookies on the proxied request. 8 | 9 | 1.4.0 10 | ----- 11 | - Pass through args and kwargs to the `proxy` method. 12 | - Added testing infrastructure. 13 | 14 | 1.3.0 15 | ----- 16 | - Optionally override SSL verification setting 17 | - Allow empty sources (request goes directly to the host) 18 | - Fixed POST requests when using json payload 19 | - Do not automatically prepend trailing slash to proxy request 20 | 21 | 1.2.0 22 | ----- 23 | - Python 3 support 24 | - Accept-Language header was set incorrectly 25 | 26 | 1.1.0 27 | ----- 28 | - Streaming upload support added 29 | 30 | 1.0.0 31 | ----- 32 | - Initial release 33 | -------------------------------------------------------------------------------- /CREDITS: -------------------------------------------------------------------------------- 1 | Thank you to following people for contributing to this project: 2 | 3 | Tomi Pajunen - https://github.com/eofs 4 | Piotr Szachewicz - https://github.com/piotr-szachewicz 5 | Sergey Kirillov - https://github.com/pistolero 6 | Ash Christopher - https://github.com/ashchristopher 7 | grudelsud - https://github.com/grudelsud 8 | Jason Filipe - https://github.com/jfilipe 9 | Joseph Kahn - https://github.com/jbkahn 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Django Rest Framework Proxy 2 | ==================== 3 | 4 | [![PyPI version](https://badge.fury.io/py/django-rest-framework-proxy.svg)](http://badge.fury.io/py/django-rest-framework-proxy) 5 | [![Build Status](https://travis-ci.org/eofs/django-rest-framework-proxy.svg?branch=master)](https://travis-ci.org/eofs/django-rest-framework-proxy) 6 | [![Coverage Status](https://coveralls.io/repos/eofs/django-rest-framework-proxy/badge.png?branch=master)](https://coveralls.io/r/eofs/django-rest-framework-proxy?branch=master) 7 | 8 | Provides views to redirect incoming request to another API server. 9 | 10 | **Features:** 11 | 12 | * Masquerade paths 13 | * HTTP Basic Auth (between your API and backend API) 14 | * Token Auth 15 | * Supported methods: GET/POST/PUT/PATCH 16 | * File uploads 17 | 18 | **TODO:** 19 | * Pass auth information from original client to backend API 20 | 21 | #Installation# 22 | 23 | ```bash 24 | $ pip install django-rest-framework-proxy 25 | ``` 26 | 27 | #Usage# 28 | There are couple of ways to use proxies. You can either use provided views as is or subclass them. 29 | 30 | ## Settings ## 31 | ```python 32 | # settings.py 33 | REST_PROXY = { 34 | 'HOST': 'https://api.example.com', 35 | 'AUTH': { 36 | 'user': 'myuser', 37 | 'password': 'mypassword', 38 | # Or alternatively: 39 | 'token': 'Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b', 40 | }, 41 | } 42 | ``` 43 | 44 | 45 | ## Simple way ## 46 | ```python 47 | # urls.py 48 | from rest_framework_proxy.views import ProxyView 49 | 50 | # Basic 51 | url(r'^item/$', ProxyView.as_view(source='items/'), name='item-list'), 52 | 53 | # With captured URL parameters 54 | url(r'^item/(?P[0-9]+)$', ProxyView.as_view(source='items/%(pk)s'), name='item-detail'), 55 | ``` 56 | ## Complex way ## 57 | ```python 58 | # views.py 59 | from rest_framework_proxy.views import ProxyView 60 | 61 | class ItemListProxy(ProxyView): 62 | """ 63 | List of items 64 | """ 65 | source = 'items/' 66 | 67 | class ItemDetailProxy(ProxyView): 68 | """ 69 | Item detail 70 | """ 71 | source = 'items/%(pk)s' 72 | 73 | ``` 74 | ```python 75 | # urls.py 76 | from views import ProxyListView, ProxyDetailView 77 | 78 | url(r'^item/$', ProxyListView.as_view(), name='item-list'), 79 | url(r'^item/(?P[0-9]+)$', ProxyDetailView.as_view(), name='item-detail'), 80 | ``` 81 | 82 | # Settings # 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 |
SettingDefaultComment
HOSTNoneProxy request to this host (e.g. https://example.com/api/).
AUTH{'user': None, 'password': None, 'token': None}Proxy requests using HTTP Basic or Token Authentication. 101 | Token is only used if user & password are not provided.
TIMEOUTNoneTimeout value for proxy requests.
ACCEPT_MAPS{'text/html': 'application/json'}Modify Accept-headers before proxying them. You can use this to disallow certain types. By default text/html is translated to return JSON data.
DISALLOWED_PARAMS('format',)Remove defined query parameters from proxy request.
120 | 121 | # SSL Verification # 122 | By default, `django-rest-framework-proxy` will verify the SSL certificates when proxying requests, defaulting 123 | to security. In some cases, it may be desirable to not verify SSL certificates. This setting can be modified 124 | by overriding the `VERIFY_SSL` value in the `REST_PROXY` settings. 125 | 126 | Additionally, one may set the `verify_proxy` settings on their proxy class: 127 | 128 | ```python 129 | # views.py 130 | from rest_framework_proxy.views import ProxyView 131 | 132 | class ItemListProxy(ProxyView): 133 | """ 134 | List of items 135 | """ 136 | source = 'items/' 137 | verify_ssl = False 138 | 139 | ``` 140 | 141 | Finally, if there is complex business logic needed to determine if one should verify SSL, then 142 | you can override the `get_verify_ssl()` method on your proxy view class: 143 | 144 | ```python 145 | # views.py 146 | from rest_framework_proxy.views import ProxyView 147 | 148 | class ItemListProxy(ProxyView): 149 | """ 150 | List of items 151 | """ 152 | source = 'items/' 153 | 154 | def get_verify_ssl(self, request): 155 | host = self.get_proxy_host(request) 156 | if host.startswith('intranet.'): 157 | return True 158 | return False 159 | 160 | ``` 161 | 162 | # Permissions # 163 | You can limit access by using Permission classes and custom Views. 164 | See http://django-rest-framework.org/api-guide/permissions.html for more information 165 | ```python 166 | # permissions.py 167 | from rest_framework.permissions import BasePermission, SAFE_METHODS 168 | 169 | class AdminOrReadOnly(BasePermission): 170 | """ 171 | Read permission for everyone. Only admins can modify content. 172 | """ 173 | def has_permission(self, request, view, obj=None): 174 | if (request.method in SAFE_METHODS or 175 | request.user and request.user.is_staff): 176 | return True 177 | return False 178 | 179 | ``` 180 | ```python 181 | # views.py 182 | from rest_framework_proxy.views import ProxyView 183 | from permissions import AdminOrReadOnly 184 | 185 | class ItemListProxy(ProxyView): 186 | permission_classes = (AdminOrReadOnly,) 187 | ``` 188 | 189 | 190 | #License# 191 | 192 | Copyright (c) 2014, Tomi Pajunen 193 | All rights reserved. 194 | 195 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 196 | 197 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 198 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 199 | 200 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 201 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 202 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements/requirements-development.txt 2 | -r requirements/requirements-testing.txt -------------------------------------------------------------------------------- /requirements/requirements-development.txt: -------------------------------------------------------------------------------- 1 | django>=1.8 2 | djangorestframework>=3.1.0 3 | requests>=1.1.0 4 | -------------------------------------------------------------------------------- /requirements/requirements-testing.txt: -------------------------------------------------------------------------------- 1 | coverage>4 2 | coveralls>1 3 | mock>=1.0.1 4 | nose>=1.3.0 5 | django-nose>=1.4.4 6 | tox>=2.1.1 7 | 8 | djangorestframework>=3.1.0 9 | -------------------------------------------------------------------------------- /rest_framework_proxy/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.6.0' 2 | -------------------------------------------------------------------------------- /rest_framework_proxy/adapters.py: -------------------------------------------------------------------------------- 1 | import socket 2 | 3 | from requests.adapters import HTTPAdapter 4 | from requests.packages.urllib3.response import HTTPResponse 5 | from requests.packages.urllib3.exceptions import MaxRetryError 6 | from requests.packages.urllib3.exceptions import TimeoutError 7 | from requests.packages.urllib3.exceptions import SSLError as _SSLError 8 | from requests.packages.urllib3.exceptions import HTTPError as _HTTPError 9 | from requests.exceptions import ConnectionError, Timeout, SSLError 10 | 11 | 12 | class StreamingHTTPAdapter(HTTPAdapter): 13 | def send(self, request, stream=False, timeout=None, verify=True, cert=None, proxies=None): 14 | """Stream PreparedRequest object. Returns Response object.""" 15 | 16 | conn = self.get_connection(request.url, proxies) 17 | 18 | self.cert_verify(conn, request.url, verify, cert) 19 | url = self.request_url(request, proxies) 20 | 21 | try: 22 | if hasattr(conn, 'proxy_pool'): 23 | conn = conn.proxy_pool 24 | 25 | low_conn = conn._get_conn(timeout=timeout) 26 | low_conn.putrequest(request.method, url, skip_accept_encoding=True) 27 | 28 | for header, value in request.headers.items(): 29 | low_conn.putheader(header, value) 30 | 31 | low_conn.endheaders() 32 | 33 | for i in request.body: 34 | low_conn.send(i) 35 | 36 | r = low_conn.getresponse() 37 | resp = HTTPResponse.from_httplib(r, 38 | pool=conn, 39 | connection=low_conn, 40 | preload_content=False, 41 | decode_content=False 42 | ) 43 | 44 | except socket.error as sockerr: 45 | raise ConnectionError(sockerr) 46 | 47 | except MaxRetryError as e: 48 | raise ConnectionError(e) 49 | 50 | except (_SSLError, _HTTPError) as e: 51 | if isinstance(e, _SSLError): 52 | raise SSLError(e) 53 | elif isinstance(e, TimeoutError): 54 | raise Timeout(e) 55 | else: 56 | raise Timeout('Request timed out.') 57 | 58 | r = self.build_response(request, resp) 59 | 60 | if not stream: 61 | r.content 62 | 63 | return r 64 | -------------------------------------------------------------------------------- /rest_framework_proxy/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eofs/django-rest-framework-proxy/1fa51b14df364e3dfca3afe57179efefc184d64f/rest_framework_proxy/models.py -------------------------------------------------------------------------------- /rest_framework_proxy/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from rest_framework.settings import APISettings 4 | 5 | 6 | USER_SETTINGS = getattr(settings, 'REST_PROXY', None) 7 | 8 | DEFAULTS = { 9 | 'HOST': None, 10 | 'AUTH': { 11 | 'user': None, 12 | 'password': None, 13 | 'token': None, 14 | }, 15 | 'TIMEOUT': None, 16 | 'DEFAULT_HTTP_ACCEPT': 'application/json', 17 | 'DEFAULT_HTTP_ACCEPT_LANGUAGE': 'en-US,en;q=0.8', 18 | 'DEFAULT_CONTENT_TYPE': 'text/plain', 19 | 20 | # Return response as-is if enabled 21 | 'RETURN_RAW': False, 22 | 23 | # Used to translate Accept HTTP field 24 | 'ACCEPT_MAPS': { 25 | 'text/html': 'application/json', 26 | }, 27 | 28 | # Do not pass following parameters 29 | 'DISALLOWED_PARAMS': ('format',), 30 | 31 | # Perform a SSL Cert Verification on URI requests are being proxied to 32 | 'VERIFY_SSL': True, 33 | } 34 | 35 | api_proxy_settings = APISettings(USER_SETTINGS, DEFAULTS) 36 | -------------------------------------------------------------------------------- /rest_framework_proxy/utils.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | 3 | from requests.packages.urllib3.filepost import choose_boundary 4 | 5 | 6 | def generate_boundary(): 7 | return choose_boundary() 8 | 9 | class StreamingMultipart(object): 10 | def __init__(self, data, files, boundary, chunk_size = 1024): 11 | self.data = data 12 | self.files = files 13 | self.boundary = boundary 14 | self.itering_files = False 15 | self.chunk_size = chunk_size 16 | 17 | def __len__(self): 18 | # TODO Optimize as currently we are iterating data and files twice 19 | # Possible solution: Cache body into file and stream from it 20 | size = 0 21 | for i in self.__iter__(): 22 | size += len(i) 23 | return size 24 | 25 | def __iter__(self): 26 | return self.generator() 27 | 28 | def generator(self): 29 | for (k, v) in self.data.items(): 30 | yield ('%s\r\n\r\n' % self.build_multipart_header(k)).encode('utf-8') 31 | yield ('%s\r\n' % str(v)).encode('utf-8') 32 | 33 | for (k, v) in self.files.items(): 34 | content_type = mimetypes.guess_type(v.name)[0] or 'application/octet-stream' 35 | yield ('%s\r\n\r\n' % self.build_multipart_header(k, v.name, content_type)).encode('utf-8') 36 | 37 | # Seek back to start as __len__ has already read the file 38 | v.seek(0) 39 | 40 | # Read file chunk by chunk 41 | while True: 42 | data = v.read(self.chunk_size) 43 | if not data: 44 | break 45 | yield data 46 | yield b'\r\n' 47 | yield self.build_multipart_footer().encode('utf-8') 48 | 49 | def build_multipart_header(self, name, filename=None, content_type=None): 50 | output = [] 51 | output.append('--%s' % self.boundary) 52 | 53 | string = 'Content-Disposition: form-data; name="%s"' % name 54 | if filename: 55 | string += '; filename="%s"' % filename 56 | output.append(string) 57 | 58 | if content_type: 59 | output.append('Content-Type: %s' % content_type) 60 | 61 | return '\r\n'.join(output) 62 | 63 | def build_multipart_footer(self): 64 | return '--%s--\r\n' % self.boundary 65 | -------------------------------------------------------------------------------- /rest_framework_proxy/views.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import requests 4 | 5 | from django.utils import six 6 | from django.utils.six import BytesIO as StringIO 7 | from requests.exceptions import ConnectionError, SSLError, Timeout 8 | from requests import sessions 9 | from django.http import HttpResponse 10 | from rest_framework.response import Response 11 | from rest_framework.views import APIView 12 | from rest_framework.utils.mediatypes import media_type_matches 13 | from rest_framework.exceptions import UnsupportedMediaType 14 | 15 | from rest_framework_proxy.settings import api_proxy_settings 16 | from rest_framework_proxy.adapters import StreamingHTTPAdapter 17 | from rest_framework_proxy.utils import StreamingMultipart, generate_boundary 18 | 19 | 20 | class BaseProxyView(APIView): 21 | proxy_settings = api_proxy_settings 22 | proxy_host = None 23 | source = None 24 | return_raw = False 25 | verify_ssl = None 26 | 27 | 28 | class ProxyView(BaseProxyView): 29 | """ 30 | Proxy view 31 | """ 32 | def get_proxy_host(self): 33 | return self.proxy_host or self.proxy_settings.HOST 34 | 35 | def get_source_path(self): 36 | if self.source: 37 | return self.source % self.kwargs 38 | return None 39 | 40 | def get_request_url(self, request): 41 | host = self.get_proxy_host() 42 | path = self.get_source_path() 43 | if path: 44 | return '/'.join([host, path]) 45 | return host 46 | 47 | def get_request_params(self, request): 48 | if request.query_params: 49 | qp = request.query_params.copy() 50 | for param in self.proxy_settings.DISALLOWED_PARAMS: 51 | if param in qp: 52 | del qp[param] 53 | return six.iterlists(qp) 54 | return {} 55 | 56 | def get_request_data(self, request): 57 | if 'application/json' in request.content_type: 58 | return json.dumps(request.data) 59 | 60 | return request.data 61 | 62 | def get_request_files(self, request): 63 | files = {} 64 | if request.FILES: 65 | for field, content in request.FILES.items(): 66 | files[field] = content 67 | return files 68 | 69 | def get_default_headers(self, request): 70 | return { 71 | 'Accept': request.META.get('HTTP_ACCEPT', self.proxy_settings.DEFAULT_HTTP_ACCEPT), 72 | 'Accept-Language': request.META.get('HTTP_ACCEPT_LANGUAGE', self.proxy_settings.DEFAULT_HTTP_ACCEPT_LANGUAGE), 73 | 'Content-Type': request.META.get('CONTENT_TYPE', self.proxy_settings.DEFAULT_CONTENT_TYPE), 74 | } 75 | 76 | def get_headers(self, request): 77 | #import re 78 | #regex = re.compile('^HTTP_') 79 | #request_headers = dict((regex.sub('', header), value) for (header, value) in request.META.items() if header.startswith('HTTP_')) 80 | headers = self.get_default_headers(request) 81 | 82 | # Translate Accept HTTP field 83 | accept_maps = self.proxy_settings.ACCEPT_MAPS 84 | for old, new in accept_maps.items(): 85 | headers['Accept'] = headers['Accept'].replace(old, new) 86 | 87 | username = self.proxy_settings.AUTH.get('user') 88 | password = self.proxy_settings.AUTH.get('password') 89 | if username and password: 90 | auth_token = '%s:%s' % (username, password) 91 | auth_token = base64.b64encode(auth_token.encode('utf-8')).decode() 92 | headers['Authorization'] = 'Basic %s' % auth_token 93 | else: 94 | auth_token = self.proxy_settings.AUTH.get('token') 95 | if auth_token: 96 | headers['Authorization'] = auth_token 97 | return headers 98 | 99 | def get_verify_ssl(self, request): 100 | return self.verify_ssl or self.proxy_settings.VERIFY_SSL 101 | 102 | def get_cookies(self, requests): 103 | return None 104 | 105 | def parse_proxy_response(self, response): 106 | """ 107 | Modified version of rest_framework.request.Request._parse(self) 108 | """ 109 | parsers = self.get_parsers() 110 | stream = StringIO(response._content) 111 | content_type = response.headers.get('content-type', None) 112 | 113 | if stream is None or content_type is None: 114 | return {} 115 | 116 | parser = None 117 | for item in parsers: 118 | if media_type_matches(item.media_type, content_type): 119 | parser = item 120 | 121 | if not parser: 122 | raise UnsupportedMediaType(content_type) 123 | 124 | parsed = parser.parse(stream, content_type) 125 | 126 | # Parser classes may return the raw data, or a 127 | # DataAndFiles object. Return only data. 128 | try: 129 | return parsed.data 130 | except AttributeError: 131 | return parsed 132 | 133 | def create_response(self, response): 134 | if self.return_raw or self.proxy_settings.RETURN_RAW: 135 | return HttpResponse(response.text, status=response.status_code, 136 | content_type=response.headers.get('content-type')) 137 | 138 | status = response.status_code 139 | if status >= 400: 140 | body = { 141 | 'code': status, 142 | 'error': response.reason, 143 | } 144 | else: 145 | body = self.parse_proxy_response(response) 146 | return Response(body, status) 147 | 148 | def create_error_response(self, body, status): 149 | return Response(body, status) 150 | 151 | def proxy(self, request, *args, **kwargs): 152 | url = self.get_request_url(request) 153 | params = self.get_request_params(request) 154 | data = self.get_request_data(request) 155 | files = self.get_request_files(request) 156 | headers = self.get_headers(request) 157 | verify_ssl = self.get_verify_ssl(request) 158 | cookies = self.get_cookies(request) 159 | 160 | try: 161 | if files: 162 | """ 163 | By default requests library uses chunked upload for files 164 | but it is much more easier for servers to handle streamed 165 | uploads. 166 | 167 | This new implementation is also lightweight as files are not 168 | read entirely into memory. 169 | """ 170 | boundary = generate_boundary() 171 | headers['Content-Type'] = 'multipart/form-data; boundary=%s' % boundary 172 | 173 | body = StreamingMultipart(data, files, boundary) 174 | 175 | session = sessions.Session() 176 | session.mount('http://', StreamingHTTPAdapter()) 177 | session.mount('https://', StreamingHTTPAdapter()) 178 | 179 | response = session.request(request.method, url, 180 | params=params, 181 | data=body, 182 | headers=headers, 183 | timeout=self.proxy_settings.TIMEOUT, 184 | verify=verify_ssl, 185 | cookies=cookies) 186 | else: 187 | response = requests.request(request.method, url, 188 | params=params, 189 | data=data, 190 | files=files, 191 | headers=headers, 192 | timeout=self.proxy_settings.TIMEOUT, 193 | verify=verify_ssl, 194 | cookies=cookies) 195 | except (ConnectionError, SSLError): 196 | status = requests.status_codes.codes.bad_gateway 197 | return self.create_error_response({ 198 | 'code': status, 199 | 'error': 'Bad gateway', 200 | }, status) 201 | except (Timeout): 202 | status = requests.status_codes.codes.gateway_timeout 203 | return self.create_error_response({ 204 | 'code': status, 205 | 'error': 'Gateway timed out', 206 | }, status) 207 | 208 | return self.create_response(response) 209 | 210 | def get(self, request, *args, **kwargs): 211 | return self.proxy(request, *args, **kwargs) 212 | 213 | def put(self, request, *args, **kwargs): 214 | return self.proxy(request, *args, **kwargs) 215 | 216 | def post(self, request, *args, **kwargs): 217 | return self.proxy(request, *args, **kwargs) 218 | 219 | def patch(self, request, *args, **kwargs): 220 | return self.proxy(request, *args, **kwargs) 221 | 222 | def delete(self, request, *args, **kwargs): 223 | return self.proxy(request, *args, **kwargs) 224 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | import sys 4 | 5 | try: 6 | from django.conf import settings 7 | 8 | settings.configure( 9 | DEBUG=True, 10 | USE_TZ=True, 11 | DATABASES={ 12 | "default": { 13 | "ENGINE": "django.db.backends.sqlite3", 14 | } 15 | }, 16 | INSTALLED_APPS=[ 17 | "django.contrib.auth", 18 | "django.contrib.contenttypes", 19 | "django.contrib.sites", 20 | "rest_framework_proxy", 21 | ], 22 | MIDDLEWARE_CLASSES=[ 23 | 'django.middleware.common.CommonMiddleware', 24 | 'django.contrib.sessions.middleware.SessionMiddleware', 25 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 26 | 'django.contrib.messages.middleware.MessageMiddleware', 27 | ], 28 | SITE_ID=1, 29 | NOSE_ARGS=['-s'], 30 | ) 31 | 32 | try: 33 | import django 34 | setup = django.setup 35 | except AttributeError: 36 | pass 37 | else: 38 | setup() 39 | 40 | from django_nose import NoseTestSuiteRunner 41 | except ImportError: 42 | import traceback 43 | traceback.print_exc() 44 | raise ImportError("To fix this error, run: pip install -r requirements/requirements-testing.txt") 45 | 46 | 47 | def run_tests(*test_args): 48 | if not test_args: 49 | test_args = ['tests'] 50 | 51 | # Run tests 52 | test_runner = NoseTestSuiteRunner(verbosity=1) 53 | 54 | failures = test_runner.run_tests(test_args) 55 | 56 | if failures: 57 | sys.exit(failures) 58 | 59 | 60 | if __name__ == '__main__': 61 | run_tests(*sys.argv[1:]) 62 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup 5 | import re 6 | import os 7 | import sys 8 | 9 | 10 | name = 'django-rest-framework-proxy' 11 | package = 'rest_framework_proxy' 12 | description = 'Django Rest Framework Proxy allows easy proxying of incoming REST requests' 13 | url = 'http://github.com/eofs/django-rest-framework-proxy/' 14 | author = 'Tomi Pajunen' 15 | author_email = 'tomi@madlab.fi' 16 | license = 'BSD' 17 | install_requires = [ 18 | 'django>=1.8', 19 | 'djangorestframework>=3.1.0', 20 | 'requests>=1.1.0' 21 | ] 22 | classifiers = [ 23 | 'Environment :: Web Environment', 24 | 'Framework :: Django', 25 | 'Framework :: Django :: 1.8', 26 | 'Framework :: Django :: 1.9', 27 | 'Framework :: Django :: 1.10', 28 | 'Framework :: Django :: 1.11', 29 | 'Intended Audience :: Developers', 30 | 'License :: OSI Approved :: BSD License', 31 | 'Operating System :: OS Independent', 32 | 'Programming Language :: Python :: 2.7', 33 | 'Programming Language :: Python :: 3', 34 | 'Programming Language :: Python :: 3.3', 35 | 'Programming Language :: Python :: 3.4', 36 | 'Programming Language :: Python :: 3.5', 37 | 'Programming Language :: Python :: 3.6', 38 | 'Topic :: Internet :: WWW/HTTP', 39 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 40 | ] 41 | 42 | 43 | def get_version(package): 44 | """ 45 | Return package version as listed in `__version__` in `init.py`. 46 | """ 47 | init_py = open(os.path.join(package, '__init__.py')).read() 48 | return re.search("^__version__ = ['\"]([^'\"]+)['\"]", init_py, re.MULTILINE).group(1) 49 | 50 | 51 | def get_packages(package): 52 | """ 53 | Return root package and all sub-packages. 54 | """ 55 | return [dirpath 56 | for dirpath, dirnames, filenames in os.walk(package) 57 | if os.path.exists(os.path.join(dirpath, '__init__.py'))] 58 | 59 | 60 | def get_package_data(package): 61 | """ 62 | Return all files under the root package, that are not in a 63 | package themselves. 64 | """ 65 | walk = [(dirpath.replace(package + os.sep, '', 1), filenames) 66 | for dirpath, dirnames, filenames in os.walk(package) 67 | if not os.path.exists(os.path.join(dirpath, '__init__.py'))] 68 | 69 | filepaths = [] 70 | for base, filenames in walk: 71 | filepaths.extend([os.path.join(base, filename) 72 | for filename in filenames]) 73 | return {package: filepaths} 74 | 75 | 76 | if sys.argv[-1] == 'publish': 77 | os.system("python setup.py sdist upload") 78 | args = {'version': get_version(package)} 79 | print ("You probably want to also tag the version now:") 80 | print (" git tag -a %(version)s -m 'version %(version)s'" % args) 81 | print (" git push --tags") 82 | sys.exit() 83 | 84 | 85 | setup( 86 | name=name, 87 | version=get_version(package), 88 | url=url, 89 | license=license, 90 | description=description, 91 | author=author, 92 | author_email=author_email, 93 | packages=get_packages(package), 94 | package_data=get_package_data(package), 95 | install_requires=install_requires, 96 | classifiers=classifiers 97 | ) 98 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eofs/django-rest-framework-proxy/1fa51b14df364e3dfca3afe57179efefc184d64f/tests/__init__.py -------------------------------------------------------------------------------- /tests/utils_test.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | import requests 3 | 4 | from io import BytesIO 5 | from django.core.files.uploadedfile import InMemoryUploadedFile 6 | from django.http.request import QueryDict 7 | from django.test import TestCase 8 | from mock import Mock, patch 9 | 10 | from rest_framework_proxy.utils import StreamingMultipart 11 | 12 | 13 | class StreamingMultipartTests(TestCase): 14 | 15 | def test_generator(self): 16 | upload_bstr = b'test binary data' 17 | upload_file = BytesIO() 18 | upload_file.write(upload_bstr) 19 | upload_file.seek(0) 20 | upload_data = InMemoryUploadedFile(upload_file, 21 | 'file', 22 | 'test_file.dat', 23 | 'application/octet-stream', 24 | len(upload_bstr), 25 | None, 26 | content_type_extra={}) 27 | 28 | data = QueryDict(mutable=True) 29 | data['file'] = upload_data 30 | files = {'file': upload_data} 31 | boundary = 'ddd37654bd80490fa3c58987954aa380' 32 | 33 | streamingMultiPart = StreamingMultipart(data, files, boundary) 34 | generator = streamingMultiPart.generator() 35 | 36 | 37 | data_mpheader = next(generator) 38 | expected_data_mpheader = b'--ddd37654bd80490fa3c58987954aa380\r\nContent-Disposition: form-data; name="file"\r\n\r\n' 39 | self.assertEqual(data_mpheader, expected_data_mpheader) 40 | 41 | data_body = next(generator) 42 | expected_data_body = b'test_file.dat\r\n' 43 | self.assertEqual(data_body, expected_data_body) 44 | 45 | file_mpheader = next(generator) 46 | content_type = mimetypes.guess_type('test_file.dat')[0] or 'application/octet-stream' 47 | expected_file_mpheader = ('--ddd37654bd80490fa3c58987954aa380\r\nContent-Disposition: form-data; name="file"; filename="test_file.dat"\r\nContent-Type: %s\r\n\r\n' % content_type).encode('utf-8') 48 | self.assertEqual(file_mpheader, expected_file_mpheader) 49 | 50 | file_body = next(generator) 51 | expected_file_body = b'test binary data' 52 | self.assertEqual(file_body, expected_file_body) 53 | self.assertEqual(next(generator), b'\r\n') 54 | 55 | mpfooter = next(generator) 56 | expected_mpfooter = b'--ddd37654bd80490fa3c58987954aa380--\r\n' 57 | self.assertEqual(mpfooter, expected_mpfooter) 58 | 59 | try: 60 | v = next(generator) 61 | self.fail('Unexpected iteration - %r' % v) 62 | except StopIteration: 63 | pass 64 | -------------------------------------------------------------------------------- /tests/views_tests.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import requests 3 | 4 | from io import BytesIO 5 | from django.core.files.uploadedfile import InMemoryUploadedFile 6 | from django.http.request import QueryDict 7 | from django.test import TestCase 8 | from mock import Mock, patch 9 | 10 | from rest_framework_proxy.views import ProxyView 11 | from rest_framework.test import APIRequestFactory 12 | from rest_framework_proxy import settings 13 | from rest_framework_proxy.utils import StreamingMultipart 14 | 15 | 16 | class ProxyViewTests(TestCase): 17 | def test_postitional_and_keyword_arguments_passed_through_to_proxy_method(self): 18 | proxied_http_methods = ['get', 'put', 'post', 'patch', 'delete'] 19 | request = Mock() 20 | view = ProxyView() 21 | 22 | for http_method in proxied_http_methods: 23 | with patch.object(ProxyView, 'proxy') as patched_proxy_method: 24 | handler = getattr(view, http_method) 25 | handler(request, 42, foo='bar') 26 | 27 | patched_proxy_method.assert_called_once_with( 28 | request, 29 | 42, 30 | foo='bar' 31 | ) 32 | 33 | def test_passes_cookies_through_to_request(self): 34 | request = Mock() 35 | view = ProxyView() 36 | view.get_cookies = lambda r: {'test_cookie': 'value'} 37 | 38 | factory = APIRequestFactory() 39 | request = factory.post('some/url', data={}, cookies={'original_request_cookie': 'I will not get passed'}) 40 | request.content_type = 'application/json' 41 | request.query_params = '' 42 | request.data = {} 43 | 44 | with patch('rest_framework_proxy.views.requests.request') as patched_requests: 45 | with patch.object(view, 'create_response'): 46 | view.proxy(request) 47 | args, kwargs = patched_requests.call_args 48 | request_cookies = kwargs['cookies'] 49 | self.assertEqual(request_cookies, {'test_cookie': 'value'}) 50 | 51 | def test_post_file(self): 52 | view = ProxyView() 53 | 54 | factory = APIRequestFactory() 55 | request = factory.post('some/url') 56 | request.content_type = 'multipart/form-data; boundary='\ 57 | '------------------------f8317b014f42e05a' 58 | request.query_params = '' 59 | 60 | upload_bstr = b'test binary data' 61 | upload_file = BytesIO() 62 | upload_file.write(upload_bstr) 63 | upload_file.seek(0) 64 | upload_data = InMemoryUploadedFile(upload_file, 65 | 'file', 66 | 'test_file.dat', 67 | 'application/octet-stream', 68 | len(upload_bstr), 69 | None, 70 | content_type_extra={}) 71 | 72 | request.data = QueryDict(mutable=True) 73 | request.data['file'] = upload_data 74 | view.get_request_files = lambda r: {'file': upload_data} 75 | 76 | with patch.object(requests.sessions.Session, 'request') as patched_request: 77 | with patch.object(view, 'create_response'): 78 | view.proxy(request) 79 | args, kwargs = patched_request.call_args 80 | request_data = kwargs['data'] 81 | self.assertEqual(request_data.files, {'file': upload_data}) 82 | self.assertEqual(request_data.data['file'], upload_data) 83 | 84 | 85 | class ProxyViewHeadersTest(TestCase): 86 | 87 | def get_view(self, custom_settings=None): 88 | view = ProxyView() 89 | view.proxy_settings = settings.APISettings( 90 | custom_settings, settings.DEFAULTS) 91 | return view 92 | 93 | def test_basic_auth(self): 94 | username, password = 'abc', 'def' 95 | view = self.get_view( 96 | {'AUTH': {'user': username, 'password': password}}) 97 | request = APIRequestFactory().post('') 98 | headers = view.get_headers(request) 99 | 100 | auth_token = '%s:%s' % (username, password) 101 | auth_token = base64.b64encode(auth_token.encode('utf-8')).decode() 102 | expected = 'Basic %s' % auth_token 103 | 104 | self.assertEqual(headers['Authorization'], expected) 105 | 106 | def test_token(self): 107 | token = 'xyz' 108 | view = self.get_view({'AUTH': {'token': token}}) 109 | request = APIRequestFactory().post('') 110 | headers = view.get_headers(request) 111 | self.assertEqual(headers['Authorization'], token) 112 | 113 | def test_basic_auth_before_token(self): 114 | username, password = 'abc', 'def' 115 | view = self.get_view( 116 | {'AUTH': {'user': username, 'password': password, 'token': 'xyz'}}) 117 | request = APIRequestFactory().post('') 118 | headers = view.get_headers(request) 119 | 120 | auth_token = '%s:%s' % (username, password) 121 | auth_token = base64.b64encode(auth_token.encode('utf-8')).decode() 122 | expected = 'Basic %s' % auth_token 123 | 124 | self.assertEqual(headers['Authorization'], expected) 125 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | {py27,py33,py34,py35}-django{17,18}, 4 | {py27,py34,py35}-django{19,110} 5 | {py27,py34,py35,py36}-django{111} 6 | 7 | [travis:env] 8 | DJANGO = 9 | 1.8: django18 10 | 1.9: django19 11 | 1.10: django110 12 | 1.11: django111 13 | 14 | [testenv] 15 | commands = coverage run --source rest_framework_proxy runtests.py 16 | setenv = 17 | PYTHONDONTWRITEBYTECODE=1 18 | 19 | deps = 20 | django18: Django==1.8,<1.9 21 | django19: Django==1.9,<1.10 22 | django110: Django==1.10,<1.11 23 | django111: Django==1.11,<2.0 24 | -rrequirements/requirements-testing.txt 25 | --------------------------------------------------------------------------------