├── .flake8 ├── .github ├── FUNDING.yml └── workflows │ ├── main.yml │ └── publish.yml ├── .gitignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── css │ └── extra.css ├── index.md ├── parsers.md └── renderers.md ├── mkdocs.yml ├── pyproject.toml ├── rest_framework_xml ├── __init__.py ├── compat.py ├── parsers.py └── renderers.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── models.py ├── test_parsers.py └── test_renderers.py └── tox.ini /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 79 3 | extend-ignore = E203, E501 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [jpadilla] 4 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: ["master"] 7 | pull_request: 8 | branches: ["master"] 9 | # Allow rebuilds via API. 10 | repository_dispatch: 11 | types: rebuild 12 | 13 | jobs: 14 | tests: 15 | name: "Python ${{ matrix.python-version }} on ${{ matrix.platform }}" 16 | runs-on: "${{ matrix.platform }}" 17 | env: 18 | USING_COVERAGE: '3.8' 19 | 20 | strategy: 21 | matrix: 22 | platform: ["ubuntu-latest"] 23 | python-version: ["3.5", "3.6", "3.7", "3.8"] 24 | 25 | steps: 26 | - uses: "actions/checkout@v2" 27 | - uses: "actions/setup-python@v1" 28 | with: 29 | python-version: "${{ matrix.python-version }}" 30 | - name: "Install dependencies" 31 | run: | 32 | python -VV 33 | python -m site 34 | python -m pip install --upgrade pip setuptools wheel 35 | python -m pip install --upgrade virtualenv tox tox-gh-actions 36 | 37 | - name: "Run tox targets for ${{ matrix.python-version }}" 38 | run: "python -m tox" 39 | env: 40 | PLATFORM: ${{ matrix.platform }} 41 | 42 | package: 43 | name: "Build & verify package" 44 | runs-on: "ubuntu-latest" 45 | 46 | steps: 47 | - uses: "actions/checkout@v2" 48 | - uses: "actions/setup-python@v1" 49 | with: 50 | python-version: "3.8" 51 | 52 | - name: "Install pep517 and twine" 53 | run: "python -m pip install pep517 twine" 54 | - name: "Build package" 55 | run: "python -m pep517.build --source --binary ." 56 | - name: "List result" 57 | run: "ls -l dist" 58 | - name: "Check long_description" 59 | run: "python -m twine check dist/*" 60 | 61 | install-dev: 62 | strategy: 63 | matrix: 64 | os: ["ubuntu-latest", "windows-latest", "macos-latest"] 65 | 66 | name: "Verify dev env" 67 | runs-on: "${{ matrix.os }}" 68 | 69 | steps: 70 | - uses: "actions/checkout@v2" 71 | - uses: "actions/setup-python@v1" 72 | with: 73 | python-version: "3.8" 74 | - name: "Install in dev mode" 75 | run: "python -m pip install -e .[dev]" 76 | - name: "Import package" 77 | run: "python -c 'import rest_framework_xml; print(rest_framework_xml.__version__)'" 78 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v1 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.db 3 | *~ 4 | .* 5 | 6 | html/ 7 | htmlcov/ 8 | coverage/ 9 | build/ 10 | dist/ 11 | *.egg-info/ 12 | MANIFEST 13 | 14 | bin/ 15 | include/ 16 | lib/ 17 | local/ 18 | 19 | !.gitignore 20 | !.github 21 | !.flake8 22 | !.isort.cfg 23 | !.pre-commit-config.yaml 24 | 25 | pip-wheel-metadata 26 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | known_third_party = django,rest_framework,setuptools 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 19.10b0 4 | hooks: 5 | - id: black 6 | language_version: python3.8 7 | 8 | - repo: https://gitlab.com/pycqa/flake8 9 | rev: 3.7.9 10 | hooks: 11 | - id: flake8 12 | language_version: python3.8 13 | 14 | - repo: https://github.com/asottile/seed-isort-config 15 | rev: v1.9.4 16 | hooks: 17 | - id: seed-isort-config 18 | 19 | - repo: https://github.com/pre-commit/mirrors-isort 20 | rev: v4.3.21 21 | hooks: 22 | - id: isort 23 | additional_dependencies: [toml] 24 | language_version: python3.8 25 | 26 | - repo: https://github.com/pre-commit/pre-commit-hooks 27 | rev: v2.4.0 28 | hooks: 29 | - id: trailing-whitespace 30 | - id: end-of-file-fixer 31 | - id: debug-statements 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, José Padilla 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-exclude * __pycache__ 2 | recursive-exclude * *.py[co] 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # REST Framework XML 2 | 3 | [![build-status-image]][github-action] 4 | [![pypi-version]][pypi] 5 | 6 | **XML support for Django REST Framework** 7 | 8 | Full documentation for the project is available at [http://jpadilla.github.io/django-rest-framework-xml][docs]. 9 | 10 | ## Overview 11 | 12 | XML support extracted as a third party package directly from the official Django REST Framework implementation. It requires the [defusedxml][defusedxml] package only because it safeguards against some security issues that were discovered. 13 | 14 | **Note**: XML output provided is an ad-hoc format that isn't formally described. If you have specific XML requirements you'll need to write your own XML parsers/renderers in order to fully control the representation. 15 | 16 | ## Requirements 17 | 18 | * Python 3.5+ 19 | * Django 2.2+ 20 | * Django REST Framework 3.11+ 21 | 22 | ## Installation 23 | 24 | Install using `pip`... 25 | 26 | ```bash 27 | $ pip install djangorestframework-xml 28 | ``` 29 | 30 | ## Example 31 | 32 | ```python 33 | REST_FRAMEWORK = { 34 | 'DEFAULT_PARSER_CLASSES': ( 35 | 'rest_framework_xml.parsers.XMLParser', 36 | ), 37 | 'DEFAULT_RENDERER_CLASSES': ( 38 | 'rest_framework_xml.renderers.XMLRenderer', 39 | ), 40 | } 41 | ``` 42 | 43 | You can also set the renderer and parser used for an individual view, or viewset, using the APIView class based views. 44 | 45 | ```python 46 | from rest_framework import routers, serializers, viewsets 47 | from rest_framework_xml.parsers import XMLParser 48 | from rest_framework_xml.renderers import XMLRenderer 49 | 50 | 51 | class UserSerializer(serializers.HyperlinkedModelSerializer): 52 | class Meta: 53 | model = User 54 | fields = ('url', 'username', 'email', 'is_staff') 55 | 56 | 57 | class UserViewSet(viewsets.ModelViewSet): 58 | queryset = User.objects.all() 59 | serializer_class = UserSerializer 60 | parser_classes = (XMLParser,) 61 | renderer_classes = (XMLRenderer,) 62 | ``` 63 | 64 | ### Sample output 65 | 66 | ```xml 67 | 68 | 69 | 70 | http://127.0.0.1:8000/users/1/.xml 71 | jpadilla 72 | jpadilla@example.com 73 | True 74 | 75 | 76 | ``` 77 | 78 | ## Documentation & Support 79 | 80 | Full documentation for the project is available at [http://jpadilla.github.io/django-rest-framework-xml][docs]. 81 | 82 | You may also want to follow the [author][jpadilla] on Twitter. 83 | 84 | 85 | [build-status-image]: https://github.com/jpadilla/django-rest-framework-xml/workflows/CI/badge.svg 86 | [github-action]: https://github.com/jpadilla/django-rest-framework-xml/actions?query=workflow%3ACI 87 | [pypi-version]: https://img.shields.io/pypi/v/djangorestframework-xml.svg 88 | [pypi]: https://pypi.python.org/pypi/djangorestframework-xml 89 | [defusedxml]: https://pypi.python.org/pypi/defusedxml 90 | [docs]: http://jpadilla.github.io/django-rest-framework-xml 91 | [jpadilla]: https://twitter.com/jpadilla_ 92 | -------------------------------------------------------------------------------- /docs/css/extra.css: -------------------------------------------------------------------------------- 1 | body.homepage div.col-md-9 h1:first-of-type { 2 | text-align: center; 3 | font-size: 60px; 4 | font-weight: 300; 5 | margin-top: 0; 6 | } 7 | 8 | body.homepage div.col-md-9 p:first-of-type { 9 | text-align: center; 10 | } 11 | 12 | body.homepage .badges { 13 | text-align: right; 14 | } 15 | 16 | body.homepage .badges a { 17 | display: inline-block; 18 | } 19 | 20 | body.homepage .badges a img { 21 | padding: 0; 22 | margin: 0; 23 | } 24 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | --- 11 | 12 | # REST Framework XML 13 | 14 | XML support for Django REST Framework 15 | 16 | --- 17 | 18 | ## Overview 19 | 20 | XML support extracted as a third party package directly from the official Django REST Framework implementation. It requires the [defusedxml][defusedxml] package only because it safeguards against some security issues that were discovered. 21 | 22 | **Note**: XML output provided is an ad-hoc format that isn't formally described. If you have specific XML requirements you'll need to write your own XML parsers/renderers in order to fully control the representation. 23 | 24 | ## Requirements 25 | 26 | * Python 3.5+ 27 | * Django 2.2+ 28 | * Django REST Framework 3.11+ 29 | 30 | ## Installation 31 | 32 | Install using `pip`... 33 | 34 | ```bash 35 | $ pip install djangorestframework-xml 36 | ``` 37 | 38 | ## Example 39 | 40 | ```python 41 | REST_FRAMEWORK = { 42 | 'DEFAULT_PARSER_CLASSES': ( 43 | 'rest_framework_xml.parsers.XMLParser', 44 | ), 45 | 'DEFAULT_RENDERER_CLASSES': ( 46 | 'rest_framework_xml.renderers.XMLRenderer', 47 | ), 48 | } 49 | ``` 50 | 51 | You can also set the renderer and parser used for an individual view, or viewset, using the APIView class based views. 52 | 53 | ```python 54 | from rest_framework import routers, serializers, viewsets 55 | from rest_framework_xml.parsers import XMLParser 56 | from rest_framework_xml.renderers import XMLRenderer 57 | 58 | 59 | class UserSerializer(serializers.HyperlinkedModelSerializer): 60 | class Meta: 61 | model = User 62 | fields = ('url', 'username', 'email', 'is_staff') 63 | 64 | 65 | class UserViewSet(viewsets.ModelViewSet): 66 | queryset = User.objects.all() 67 | serializer_class = UserSerializer 68 | parser_classes = (XMLParser,) 69 | renderer_classes = (XMLRenderer,) 70 | ``` 71 | 72 | ### Sample output 73 | 74 | ```xml 75 | 76 | 77 | 78 | http://127.0.0.1:8000/users/1/.xml 79 | jpadilla 80 | jpadilla@example.com 81 | True 82 | 83 | 84 | ``` 85 | 86 | ## Testing 87 | 88 | Install testing requirements. 89 | 90 | ```bash 91 | $ pip install -e '.[dev]' 92 | ``` 93 | 94 | Run with pytest. 95 | 96 | ```bash 97 | $ pytest 98 | ``` 99 | 100 | You can also use the excellent [tox](http://tox.readthedocs.org/en/latest/) testing tool to run the tests against all supported versions of Python and Django. Install tox globally, and then simply run: 101 | 102 | ```bash 103 | $ tox 104 | ``` 105 | 106 | ## Documentation 107 | 108 | To build the documentation, you'll need to install `mkdocs`. 109 | 110 | ```bash 111 | $ pip install mkdocs 112 | ``` 113 | 114 | To preview the documentation: 115 | 116 | ```bash 117 | $ mkdocs serve 118 | Running at: http://127.0.0.1:8000/ 119 | ``` 120 | 121 | To build the documentation: 122 | 123 | ```bash 124 | $ mkdocs build 125 | ``` 126 | 127 | 128 | [defusedxml]: https://pypi.python.org/pypi/defusedxml 129 | -------------------------------------------------------------------------------- /docs/parsers.md: -------------------------------------------------------------------------------- 1 | # Parsers 2 | 3 | ## Setting the parsers 4 | 5 | The default set of parsers may be set globally, using the `DEFAULT_PARSER_CLASSES` setting. For example, the following settings would allow requests with `XML` content. 6 | 7 | REST_FRAMEWORK = { 8 | 'DEFAULT_PARSER_CLASSES': ( 9 | 'rest_framework_xml.parsers.XMLParser', 10 | ) 11 | } 12 | 13 | You can also set the parsers used for an individual view, or viewset, 14 | using the `APIView` class based views. 15 | 16 | from rest_framework.response import Response 17 | from rest_framework.views import APIView 18 | from rest_framework_xml.parsers import XMLParser 19 | 20 | class ExampleView(APIView): 21 | """ 22 | A view that can accept POST requests with XML content. 23 | """ 24 | parser_classes = (XMLParser,) 25 | 26 | def post(self, request, format=None): 27 | return Response({'received data': request.DATA}) 28 | 29 | Or, if you're using the `@api_view` decorator with function based views. 30 | 31 | @api_view(['POST']) 32 | @parser_classes((XMLParser,)) 33 | def example_view(request, format=None): 34 | """ 35 | A view that can accept POST requests with XML content. 36 | """ 37 | return Response({'received data': request.DATA}) 38 | 39 | --- 40 | 41 | # API Reference 42 | 43 | ## XMLParser 44 | 45 | Parses REST framework's default style of `XML` request content. 46 | 47 | Note that the `XML` markup language is typically used as the base language for more strictly defined domain-specific languages, such as `RSS`, `Atom`, and `XHTML`. 48 | 49 | If you are considering using `XML` for your API, you may want to consider implementing a custom renderer and parser for your specific requirements, and using an existing domain-specific media-type, or creating your own custom XML-based media-type. 50 | 51 | Requires the `defusedxml` package to be installed. 52 | 53 | **.media_type**: `application/xml` 54 | -------------------------------------------------------------------------------- /docs/renderers.md: -------------------------------------------------------------------------------- 1 | # Renderers 2 | 3 | ## Setting the renderers 4 | 5 | The default set of renderers may be set globally, using the `DEFAULT_RENDERER_CLASSES` setting. For example, the following settings would use `XML` as the main media type and also include the self describing API. 6 | 7 | REST_FRAMEWORK = { 8 | 'DEFAULT_RENDERER_CLASSES': ( 9 | 'rest_framework_xml.renderers.XMLRenderer', 10 | ) 11 | } 12 | 13 | You can also set the renderers used for an individual view, or viewset, 14 | using the `APIView` class based views. 15 | 16 | from django.contrib.auth.models import User 17 | from rest_framework.response import Response 18 | from rest_framework.views import APIView 19 | from rest_framework_xml.renderers import XMLRenderer 20 | 21 | class UserCountView(APIView): 22 | """ 23 | A view that returns the count of active users in XML. 24 | """ 25 | renderer_classes = (XMLRenderer,) 26 | 27 | def get(self, request, format=None): 28 | user_count = User.objects.filter(active=True).count() 29 | content = {'user_count': user_count} 30 | return Response(content) 31 | 32 | Or, if you're using the `@api_view` decorator with function based views. 33 | 34 | @api_view(['GET']) 35 | @renderer_classes((XMLRenderer,)) 36 | def user_count_view(request, format=None): 37 | """ 38 | A view that returns the count of active users in XML. 39 | """ 40 | user_count = User.objects.filter(active=True).count() 41 | content = {'user_count': user_count} 42 | return Response(content) 43 | 44 | --- 45 | 46 | # API Reference 47 | 48 | ## XMLRenderer 49 | 50 | Renders REST framework's default style of `XML` response content. 51 | 52 | Note that the `XML` markup language is used typically used as the base language for more strictly defined domain-specific languages, such as `RSS`, `Atom`, and `XHTML`. 53 | 54 | If you are considering using `XML` for your API, you may want to consider implementing a custom renderer and parser for your specific requirements, and using an existing domain-specific media-type, or creating your own custom XML-based media-type. 55 | 56 | **.media_type**: `application/xml` 57 | 58 | **.format**: `'.xml'` 59 | 60 | **.charset**: `utf-8` 61 | 62 | **item_tag_name**: `list-item` 63 | 64 | **.root_tag_name**: `root` 65 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: djangorestframework-xml 2 | site_description: XML support for Django REST Framework 3 | repo_url: https://github.com/jpadilla/django-rest-framework-xml 4 | site_dir: html 5 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | 6 | [tool.black] 7 | line-length = 79 8 | 9 | 10 | [tool.isort] 11 | atomic=true 12 | force_grid_wrap=0 13 | include_trailing_comma=true 14 | lines_after_imports=2 15 | lines_between_types=1 16 | multi_line_output=3 17 | use_parentheses=true 18 | combine_as_imports=true 19 | 20 | known_first_party="rest_framework_xml" 21 | known_third_party=[] 22 | -------------------------------------------------------------------------------- /rest_framework_xml/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.0.0" 2 | -------------------------------------------------------------------------------- /rest_framework_xml/compat.py: -------------------------------------------------------------------------------- 1 | """ 2 | The `compat` module provides support for backwards compatibility with older 3 | versions of django/python, and compatibility wrappers around optional packages. 4 | """ 5 | # flake8: noqa 6 | 7 | 8 | try: 9 | import defusedxml.ElementTree as etree 10 | except ImportError: 11 | etree = None 12 | -------------------------------------------------------------------------------- /rest_framework_xml/parsers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides XML parsing support. 3 | """ 4 | import datetime 5 | import decimal 6 | 7 | from django.conf import settings 8 | from rest_framework.exceptions import ParseError 9 | from rest_framework.parsers import BaseParser 10 | 11 | from .compat import etree 12 | 13 | 14 | class XMLParser(BaseParser): 15 | """ 16 | XML parser. 17 | """ 18 | 19 | media_type = "application/xml" 20 | 21 | def parse(self, stream, media_type=None, parser_context=None): 22 | """ 23 | Parses the incoming bytestream as XML and returns the resulting data. 24 | """ 25 | assert etree, "XMLParser requires defusedxml to be installed" 26 | 27 | parser_context = parser_context or {} 28 | encoding = parser_context.get("encoding", settings.DEFAULT_CHARSET) 29 | parser = etree.DefusedXMLParser(encoding=encoding) 30 | try: 31 | tree = etree.parse(stream, parser=parser, forbid_dtd=True) 32 | except (etree.ParseError, ValueError) as exc: 33 | raise ParseError("XML parse error - %s" % str(exc)) 34 | data = self._xml_convert(tree.getroot()) 35 | 36 | return data 37 | 38 | def _xml_convert(self, element): 39 | """ 40 | convert the xml `element` into the corresponding python object 41 | """ 42 | 43 | children = list(element) 44 | 45 | if len(children) == 0: 46 | return self._type_convert(element.text) 47 | else: 48 | # if the fist child tag is list-item means all children are list-item 49 | if children[0].tag == "list-item": 50 | data = [] 51 | for child in children: 52 | data.append(self._xml_convert(child)) 53 | else: 54 | data = {} 55 | for child in children: 56 | data[child.tag] = self._xml_convert(child) 57 | 58 | return data 59 | 60 | def _type_convert(self, value): 61 | """ 62 | Converts the value returned by the XMl parse into the equivalent 63 | Python type 64 | """ 65 | if value is None: 66 | return value 67 | 68 | try: 69 | return datetime.datetime.strptime(value, "%Y-%m-%d %H:%M:%S") 70 | except ValueError: 71 | pass 72 | 73 | try: 74 | return int(value) 75 | except ValueError: 76 | pass 77 | 78 | try: 79 | return decimal.Decimal(value) 80 | except decimal.InvalidOperation: 81 | pass 82 | 83 | return value 84 | -------------------------------------------------------------------------------- /rest_framework_xml/renderers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides XML rendering support. 3 | """ 4 | from io import StringIO 5 | 6 | from django.utils.encoding import force_str 7 | from django.utils.xmlutils import SimplerXMLGenerator 8 | from rest_framework.renderers import BaseRenderer 9 | 10 | 11 | class XMLRenderer(BaseRenderer): 12 | """ 13 | Renderer which serializes to XML. 14 | """ 15 | 16 | media_type = "application/xml" 17 | format = "xml" 18 | charset = "utf-8" 19 | item_tag_name = "list-item" 20 | root_tag_name = "root" 21 | 22 | def render(self, data, accepted_media_type=None, renderer_context=None): 23 | """ 24 | Renders `data` into serialized XML. 25 | """ 26 | if data is None: 27 | return "" 28 | 29 | stream = StringIO() 30 | 31 | xml = SimplerXMLGenerator(stream, self.charset) 32 | xml.startDocument() 33 | xml.startElement(self.root_tag_name, {}) 34 | 35 | self._to_xml(xml, data) 36 | 37 | xml.endElement(self.root_tag_name) 38 | xml.endDocument() 39 | return stream.getvalue() 40 | 41 | def _to_xml(self, xml, data): 42 | if isinstance(data, (list, tuple)): 43 | for item in data: 44 | xml.startElement(self.item_tag_name, {}) 45 | self._to_xml(xml, item) 46 | xml.endElement(self.item_tag_name) 47 | 48 | elif isinstance(data, dict): 49 | for key, value in data.items(): 50 | xml.startElement(key, {}) 51 | self._to_xml(xml, value) 52 | xml.endElement(key) 53 | 54 | elif data is None: 55 | # Don't output any value 56 | pass 57 | 58 | else: 59 | xml.characters(force_str(data)) 60 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import re 5 | import sys 6 | 7 | from setuptools import setup 8 | from setuptools.command.test import test as TestCommand 9 | 10 | 11 | # This command has been borrowed from 12 | # https://github.com/getsentry/sentry/blob/master/setup.py 13 | class PyTest(TestCommand): 14 | def finalize_options(self): 15 | TestCommand.finalize_options(self) 16 | self.test_args = ["tests"] 17 | self.test_suite = True 18 | 19 | def run_tests(self): 20 | import pytest 21 | 22 | errno = pytest.main(self.test_args) 23 | sys.exit(errno) 24 | 25 | 26 | def read(f): 27 | return open(f, "r", encoding="utf-8").read() 28 | 29 | 30 | def get_version(package): 31 | """ 32 | Return package version as listed in `__version__` in `init.py`. 33 | """ 34 | init_py = open(os.path.join(package, "__init__.py")).read() 35 | return re.search( 36 | "^__version__ = ['\"]([^'\"]+)['\"]", init_py, re.MULTILINE 37 | ).group(1) 38 | 39 | 40 | def get_packages(package): 41 | """ 42 | Return root package and all sub-packages. 43 | """ 44 | return [ 45 | dirpath 46 | for dirpath, dirnames, filenames in os.walk(package) 47 | if os.path.exists(os.path.join(dirpath, "__init__.py")) 48 | ] 49 | 50 | 51 | def get_package_data(package): 52 | """ 53 | Return all files under the root package, that are not in a 54 | package themselves. 55 | """ 56 | walk = [ 57 | (dirpath.replace(package + os.sep, "", 1), filenames) 58 | for dirpath, dirnames, filenames in os.walk(package) 59 | if not os.path.exists(os.path.join(dirpath, "__init__.py")) 60 | ] 61 | 62 | filepaths = [] 63 | for base, filenames in walk: 64 | filepaths.extend( 65 | [os.path.join(base, filename) for filename in filenames] 66 | ) 67 | return {package: filepaths} 68 | 69 | 70 | name = "djangorestframework-xml" 71 | package = "rest_framework_xml" 72 | version = get_version(package) 73 | description = "XML support for Django REST Framework" 74 | url = "https://github.com/jpadilla/django-rest-framework-xml" 75 | author = "José Padilla" 76 | author_email = "hello@jpadilla.com" 77 | license = "BSD" 78 | install_requires = ["defusedxml>=0.6.0"] 79 | extras_requires = { 80 | "docs": ["mkdocs>=0.11.1"], 81 | "tests": [ 82 | "Django>=1.6", 83 | "djangorestframework>=2.4.3", 84 | "pytest-django", 85 | "pytest", 86 | "flake8", 87 | ], 88 | } 89 | 90 | extras_requires["dev"] = ( 91 | extras_requires["docs"] + extras_requires["tests"] + ["tox", "pre-commit"] 92 | ) 93 | 94 | 95 | if sys.argv[-1] == "publish": 96 | os.system("python setup.py sdist upload") 97 | os.system("python setup.py bdist_wheel upload") 98 | print("You probably want to also tag the version now:") 99 | print(" git tag -a {0} -m 'version {0}'".format(version)) 100 | print(" git push --tags") 101 | sys.exit() 102 | 103 | setup( 104 | name=name, 105 | version=version, 106 | url=url, 107 | license=license, 108 | description=description, 109 | long_description=read("README.md"), 110 | long_description_content_type="text/markdown", 111 | author=author, 112 | author_email=author_email, 113 | packages=get_packages(package), 114 | package_data=get_package_data(package), 115 | cmdclass={"test": PyTest}, 116 | install_requires=install_requires, 117 | extras_require=extras_requires, 118 | python_requires=">=3.5", 119 | classifiers=[ 120 | "Development Status :: 5 - Production/Stable", 121 | "Environment :: Web Environment", 122 | "Framework :: Django", 123 | "Intended Audience :: Developers", 124 | "License :: OSI Approved :: BSD License", 125 | "Operating System :: OS Independent", 126 | "Natural Language :: English", 127 | "Programming Language :: Python :: 3", 128 | "Programming Language :: Python :: 3.5", 129 | "Programming Language :: Python :: 3.6", 130 | "Programming Language :: Python :: 3.7", 131 | "Programming Language :: Python :: 3.8", 132 | "Topic :: Internet :: WWW/HTTP", 133 | ], 134 | ) 135 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jpadilla/django-rest-framework-xml/da33b6bd54e8e434a8218034f41678ec9d4a826e/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | def pytest_configure(): 2 | from django.conf import settings 3 | 4 | settings.configure( 5 | DEBUG_PROPAGATE_EXCEPTIONS=True, 6 | DATABASES={ 7 | "default": { 8 | "ENGINE": "django.db.backends.sqlite3", 9 | "NAME": ":memory:", 10 | } 11 | }, 12 | SITE_ID=1, 13 | SECRET_KEY="not very secret in tests", 14 | USE_I18N=True, 15 | USE_L10N=True, 16 | STATIC_URL="/static/", 17 | ROOT_URLCONF="tests.urls", 18 | TEMPLATE_LOADERS=( 19 | "django.template.loaders.filesystem.Loader", 20 | "django.template.loaders.app_directories.Loader", 21 | ), 22 | MIDDLEWARE_CLASSES=( 23 | "django.middleware.common.CommonMiddleware", 24 | "django.contrib.sessions.middleware.SessionMiddleware", 25 | "django.middleware.csrf.CsrfViewMiddleware", 26 | "django.contrib.auth.middleware.AuthenticationMiddleware", 27 | "django.contrib.messages.middleware.MessageMiddleware", 28 | ), 29 | INSTALLED_APPS=( 30 | "django.contrib.auth", 31 | "django.contrib.contenttypes", 32 | "django.contrib.sessions", 33 | "django.contrib.sites", 34 | "django.contrib.messages", 35 | "django.contrib.staticfiles", 36 | "rest_framework", 37 | "rest_framework.authtoken", 38 | "tests", 39 | ), 40 | PASSWORD_HASHERS=( 41 | "django.contrib.auth.hashers.SHA1PasswordHasher", 42 | "django.contrib.auth.hashers.PBKDF2PasswordHasher", 43 | "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher", 44 | "django.contrib.auth.hashers.BCryptPasswordHasher", 45 | "django.contrib.auth.hashers.MD5PasswordHasher", 46 | "django.contrib.auth.hashers.CryptPasswordHasher", 47 | ), 48 | ) 49 | 50 | try: 51 | import oauth_provider # NOQA 52 | import oauth2 # NOQA 53 | except ImportError: 54 | pass 55 | else: 56 | settings.INSTALLED_APPS += ("oauth_provider",) 57 | 58 | try: 59 | import provider # NOQA 60 | except ImportError: 61 | pass 62 | else: 63 | settings.INSTALLED_APPS += ( 64 | "provider", 65 | "provider.oauth2", 66 | ) 67 | 68 | # guardian is optional 69 | try: 70 | import guardian # NOQA 71 | except ImportError: 72 | pass 73 | else: 74 | settings.ANONYMOUS_USER_ID = -1 75 | settings.AUTHENTICATION_BACKENDS = ( 76 | "django.contrib.auth.backends.ModelBackend", 77 | "guardian.backends.ObjectPermissionBackend", 78 | ) 79 | settings.INSTALLED_APPS += ("guardian",) 80 | 81 | try: 82 | import django 83 | 84 | django.setup() 85 | except AttributeError: 86 | pass 87 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jpadilla/django-rest-framework-xml/da33b6bd54e8e434a8218034f41678ec9d4a826e/tests/models.py -------------------------------------------------------------------------------- /tests/test_parsers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | 4 | from io import StringIO 5 | 6 | from django.test import TestCase 7 | from django.test.utils import skipUnless 8 | 9 | from rest_framework_xml.compat import etree 10 | from rest_framework_xml.parsers import XMLParser 11 | 12 | 13 | class TestXMLParser(TestCase): 14 | def setUp(self): 15 | self._input = StringIO( 16 | '' 17 | "" 18 | "121.0" 19 | "dasd" 20 | "" 21 | "2011-12-25 12:45:00" 22 | "" 23 | ) 24 | self._data = { 25 | "field_a": 121, 26 | "field_b": "dasd", 27 | "field_c": None, 28 | "field_d": datetime.datetime(2011, 12, 25, 12, 45, 00), 29 | } 30 | self._complex_data_input = StringIO( 31 | '' 32 | "" 33 | "2011-12-25 12:45:00" 34 | "" 35 | "1first" 36 | "2second" 37 | "" 38 | "name" 39 | "" 40 | ) 41 | self._complex_data = { 42 | "creation_date": datetime.datetime(2011, 12, 25, 12, 45, 00), 43 | "name": "name", 44 | "sub_data_list": [ 45 | {"sub_id": 1, "sub_name": "first"}, 46 | {"sub_id": 2, "sub_name": "second"}, 47 | ], 48 | } 49 | 50 | @skipUnless(etree, "defusedxml not installed") 51 | def test_parse(self): 52 | parser = XMLParser() 53 | data = parser.parse(self._input) 54 | self.assertEqual(data, self._data) 55 | 56 | @skipUnless(etree, "defusedxml not installed") 57 | def test_complex_data_parse(self): 58 | parser = XMLParser() 59 | data = parser.parse(self._complex_data_input) 60 | self.assertEqual(data, self._complex_data) 61 | -------------------------------------------------------------------------------- /tests/test_renderers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | 4 | from decimal import Decimal 5 | from io import StringIO 6 | 7 | from django.test import TestCase 8 | from django.test.utils import skipUnless 9 | from django.utils.translation import gettext_lazy 10 | 11 | from rest_framework_xml.compat import etree 12 | from rest_framework_xml.parsers import XMLParser 13 | from rest_framework_xml.renderers import XMLRenderer 14 | 15 | 16 | class XMLRendererTestCase(TestCase): 17 | """ 18 | Tests specific to the XML Renderer 19 | """ 20 | 21 | _complex_data = { 22 | "creation_date": datetime.datetime(2011, 12, 25, 12, 45, 00), 23 | "name": "name", 24 | "sub_data_list": [ 25 | {"sub_id": 1, "sub_name": "first"}, 26 | {"sub_id": 2, "sub_name": "second"}, 27 | ], 28 | } 29 | 30 | def test_render_string(self): 31 | """ 32 | Test XML rendering. 33 | """ 34 | renderer = XMLRenderer() 35 | content = renderer.render({"field": "astring"}, "application/xml") 36 | self.assertXMLContains(content, "astring") 37 | 38 | def test_render_integer(self): 39 | """ 40 | Test XML rendering. 41 | """ 42 | renderer = XMLRenderer() 43 | content = renderer.render({"field": 111}, "application/xml") 44 | self.assertXMLContains(content, "111") 45 | 46 | def test_render_datetime(self): 47 | """ 48 | Test XML rendering. 49 | """ 50 | renderer = XMLRenderer() 51 | content = renderer.render( 52 | {"field": datetime.datetime(2011, 12, 25, 12, 45, 00)}, 53 | "application/xml", 54 | ) 55 | self.assertXMLContains(content, "2011-12-25 12:45:00") 56 | 57 | def test_render_float(self): 58 | """ 59 | Test XML rendering. 60 | """ 61 | renderer = XMLRenderer() 62 | content = renderer.render({"field": 123.4}, "application/xml") 63 | self.assertXMLContains(content, "123.4") 64 | 65 | def test_render_decimal(self): 66 | """ 67 | Test XML rendering. 68 | """ 69 | renderer = XMLRenderer() 70 | content = renderer.render( 71 | {"field": Decimal("111.2")}, "application/xml" 72 | ) 73 | self.assertXMLContains(content, "111.2") 74 | 75 | def test_render_none(self): 76 | """ 77 | Test XML rendering. 78 | """ 79 | renderer = XMLRenderer() 80 | content = renderer.render({"field": None}, "application/xml") 81 | self.assertXMLContains(content, "") 82 | 83 | def test_render_complex_data(self): 84 | """ 85 | Test XML rendering. 86 | """ 87 | renderer = XMLRenderer() 88 | content = renderer.render(self._complex_data, "application/xml") 89 | self.assertXMLContains(content, "first") 90 | self.assertXMLContains(content, "second") 91 | 92 | def test_render_list(self): 93 | renderer = XMLRenderer() 94 | content = renderer.render(self._complex_data, "application/xml") 95 | self.assertXMLContains(content, "") 96 | self.assertXMLContains(content, "") 97 | 98 | def test_render_lazy(self): 99 | renderer = XMLRenderer() 100 | lazy = gettext_lazy("hello") 101 | content = renderer.render({"field": lazy}, "application/xml") 102 | self.assertXMLContains(content, "hello") 103 | 104 | @skipUnless(etree, "defusedxml not installed") 105 | def test_render_and_parse_complex_data(self): 106 | """ 107 | Test XML rendering. 108 | """ 109 | renderer = XMLRenderer() 110 | content = StringIO( 111 | renderer.render(self._complex_data, "application/xml") 112 | ) 113 | 114 | parser = XMLParser() 115 | complex_data_out = parser.parse(content) 116 | error_msg = "complex data differs!IN:\n %s \n\n OUT:\n %s" % ( 117 | repr(self._complex_data), 118 | repr(complex_data_out), 119 | ) 120 | self.assertEqual(self._complex_data, complex_data_out, error_msg) 121 | 122 | def assertXMLContains(self, xml, string): 123 | self.assertTrue( 124 | xml.startswith('\n') 125 | ) 126 | self.assertTrue(xml.endswith("")) 127 | self.assertTrue(string in xml, "%r not in %r" % (string, xml)) 128 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | strict = true 3 | addopts = -ra 4 | testpaths = tests 5 | filterwarnings = 6 | once::Warning 7 | ignore:::pympler[.*] 8 | 9 | 10 | [gh-actions] 11 | python = 12 | 3.5: py35 13 | 3.6: py36 14 | 3.7: py37 15 | 3.8: py38, lint, docs 16 | 17 | 18 | [tox] 19 | envlist = 20 | lint 21 | {py35,py36,py37}-django2.2-drf3.11 22 | {py36,py37,py38}-django3.0-drf3.11 23 | docs 24 | isolated_build = True 25 | 26 | 27 | [testenv] 28 | commands = pytest {posargs} 29 | deps = 30 | django2.2: Django==2.2.* 31 | django3.0: Django==3.0.* 32 | drf3.11: djangorestframework==3.11.* 33 | pytest-django 34 | 35 | 36 | [testenv:lint] 37 | basepython = python3.8 38 | extras = dev 39 | passenv = HOMEPATH # needed on Windows 40 | commands = pre-commit run --all-files 41 | 42 | 43 | [testenv:docs] 44 | basepython = python3.8 45 | extras = docs 46 | commands = mkdocs build 47 | --------------------------------------------------------------------------------