├── .github └── workflows │ └── just_test.yml ├── .gitignore ├── .readthedocs.yaml ├── AUTHORS.md ├── LICENSE ├── README.md ├── docs ├── Makefile ├── _static │ └── .keep ├── _templates │ └── .keep ├── api │ ├── exceptions.rst │ ├── http.rst │ ├── serializers.rst │ ├── tests.rst │ └── views.rst ├── conf.py ├── index.rst ├── make.bat ├── requirements.txt └── usage │ ├── quickstart.rst │ ├── testing.rst │ └── tutorial.rst ├── justfile ├── pyproject.toml ├── src └── microapi │ ├── __init__.py │ ├── exceptions.py │ ├── http.py │ ├── serializers.py │ ├── tests.py │ └── views.py └── test ├── config ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py └── wsgi.py ├── manage.py └── test_microapi ├── __init__.py ├── apps.py ├── migrations ├── 0001_initial.py ├── 0002_alter_blogpost_published_on_alter_blogpost_slug.py └── __init__.py ├── models.py ├── tests ├── __init__.py ├── test_serializers.py └── test_views.py ├── urls.py └── views.py /.github/workflows/just_test.yml: -------------------------------------------------------------------------------- 1 | name: just_test 2 | on: [pull_request, push] 3 | jobs: 4 | just_test: 5 | strategy: 6 | fail-fast: false 7 | matrix: 8 | os: [macos-latest, ubuntu-latest, windows-latest] 9 | runs-on: ${{ matrix.os }} 10 | steps: 11 | - if: ${{ startsWith(matrix.os, 'macos') }} 12 | run: brew install just 13 | - if: ${{ startsWith(matrix.os, 'ubuntu') }} 14 | run: sudo snap install --edge --classic just 15 | - if: ${{ startsWith(matrix.os, 'windows') }} 16 | run: choco install just 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-python@v5 19 | with: 20 | python-version: 3.x 21 | - run: pip install --upgrade pip 22 | - run: pip install pipenv 23 | - run: just test 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | 3 | env 4 | __pycache__ 5 | 6 | .env 7 | Pipfile 8 | Pipfile.lock 9 | 10 | build 11 | dist 12 | **/*.egg-info 13 | docs/_build 14 | 15 | test/db.sqlite3 16 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | 14 | # Build documentation in the docs/ directory with Sphinx 15 | sphinx: 16 | configuration: docs/conf.py 17 | 18 | # We recommend specifying your dependencies to enable reproducible builds: 19 | # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 20 | python: 21 | install: 22 | - method: pip 23 | path: . 24 | - requirements: docs/requirements.txt 25 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | Primary Authors: 2 | 3 | * Daniel Lindsley (@toastdriven) 4 | 5 | 6 | Contributors: 7 | 8 | * Christian Clauss (@cclauss) 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Daniel Lindsley 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, 7 | this list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | 3. Neither the name of the copyright holder nor the names of its contributors 14 | may be used to endorse or promote products derived from this software without 15 | specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 18 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 19 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 20 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 21 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 22 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 23 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 24 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 25 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 26 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 27 | POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-microapi 2 | 3 | [![Documentation Status](https://readthedocs.org/projects/django-microapi/badge/?version=latest)](https://django-microapi.readthedocs.io/en/latest/?badge=latest) 4 | 5 | A tiny library to make writing CBV-based APIs easier in Django. 6 | 7 | Essentially, this just provides some sugar on top of the plain old `django.views.generic.base.View` class, all with the intent of making handling JSON APIs easier (without the need for a full framework). 8 | 9 | 10 | ## Usage 11 | 12 | ```python 13 | # We pull in two useful classes from `microapi`. 14 | from microapi import ApiView, ModelSerializer 15 | 16 | from .models import BlogPost 17 | 18 | 19 | # Inherit from the `ApiView` class... 20 | class BlogPostView(ApiView): 21 | # ...then define `get`/`post`/`put`/`delete`/`patch` methods on the 22 | # subclass. 23 | 24 | # For example, we'll provide a list view on `get`. 25 | def get(self, request): 26 | posts = BlogPost.objects.all().order_by("-created") 27 | 28 | # The `render` method automatically creates a JSON response from 29 | # the provided data. 30 | return self.render({ 31 | "success": True, 32 | "posts": self.serialize_many(posts), 33 | }) 34 | 35 | # And handle creating a new blog post on `post`. 36 | def post(self, request): 37 | if not request.user.is_authenticated: 38 | return self.render_error("You must be logged in") 39 | 40 | # Read the JSON 41 | data = self.read_json(request) 42 | 43 | # TODO: Validate the data here. 44 | 45 | # Use the included `ModelSerializer` to load the user-provided data 46 | # into a new `BlogPost`. 47 | post = self.serializer.from_dict(BlogPost(), data) 48 | # Don't forget to save! 49 | post.save() 50 | 51 | return self.render({ 52 | "success": True, 53 | "post": self.serialize(post), 54 | }) 55 | ``` 56 | 57 | 58 | ## Installation 59 | 60 | ```bash 61 | $ pip install django-microapi 62 | ``` 63 | 64 | 65 | ## Rationale 66 | 67 | There are a lot of API frameworks out there (hell, I [built](https://tastypieapi.org/) [two](https://github.com/toastdriven/restless) of them). But for many tasks, they're either [overkill](https://en.wikipedia.org/wiki/HATEOAS) or just too opinionated. 68 | 69 | So `django-microapi` is kind of the antithesis to those. With the exception of a tiny extension to `View` for nicer errors, it doesn't call **ANYTHING** automatically. Other than being JSON-based, it doesn't have opinions on serialization, or validation, or URL structures. 70 | 71 | You write the endpoints you want, and `microapi` brings some conveniences to the table to make writing that endpoint as simple as possible _without_ assumptions. 72 | 73 | I've long had a place in my heart for the simplicity of Django's function-based views, as well as the conveniences of `django.shortcuts`. `microapi` tries to channel that love/simplicity. 74 | 75 | 76 | ## API Docs 77 | 78 | https://django-microapi.rtfd.io/ 79 | 80 | 81 | ## Testing 82 | 83 | To run the tests, you'll need both [Pipenv](https://pipenv.pypa.io/en/latest/), and [Just](https://just.systems/) installed. 84 | 85 | ```shell 86 | $ just test 87 | ``` 88 | 89 | 90 | ## License 91 | 92 | New BSD 93 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 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 | -------------------------------------------------------------------------------- /docs/_static/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toastdriven/django-microapi/dab8c071466bdcb326e1990984f6786e4b433382/docs/_static/.keep -------------------------------------------------------------------------------- /docs/_templates/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toastdriven/django-microapi/dab8c071466bdcb326e1990984f6786e4b433382/docs/_templates/.keep -------------------------------------------------------------------------------- /docs/api/exceptions.rst: -------------------------------------------------------------------------------- 1 | `microapi.exceptions` 2 | ===================== 3 | 4 | .. automodule:: microapi.exceptions 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/http.rst: -------------------------------------------------------------------------------- 1 | `microapi.http` 2 | =============== 3 | 4 | Some useful constants for use elsewhere in your application code: 5 | 6 | .. automodule:: microapi.http 7 | :members: 8 | :undoc-members: 9 | 10 | .. autoattribute:: microapi.http.OK 11 | .. autoattribute:: microapi.http.CREATED 12 | .. autoattribute:: microapi.http.ACCEPTED 13 | .. autoattribute:: microapi.http.NO_CONTENT 14 | .. autoattribute:: microapi.http.BAD_REQUEST 15 | .. autoattribute:: microapi.http.UNAUTHORIZED 16 | .. autoattribute:: microapi.http.FORBIDDEN 17 | .. autoattribute:: microapi.http.NOT_FOUND 18 | .. autoattribute:: microapi.http.NOT_ALLOWED 19 | .. autoattribute:: microapi.http.IM_A_TEAPOT 20 | .. autoattribute:: microapi.http.APP_ERROR 21 | -------------------------------------------------------------------------------- /docs/api/serializers.rst: -------------------------------------------------------------------------------- 1 | `microapi.serializers` 2 | ====================== 3 | 4 | .. automodule:: microapi.serializers 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/tests.rst: -------------------------------------------------------------------------------- 1 | `microapi.tests` 2 | ================ 3 | 4 | .. automodule:: microapi.tests 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/views.rst: -------------------------------------------------------------------------------- 1 | `microapi.views` 2 | ================ 3 | 4 | .. automodule:: microapi.views 5 | :members: 6 | :undoc-members: 7 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | import sys, os 10 | 11 | sys.path.insert(0, os.path.abspath("../src")) 12 | 13 | project = "django-microapi" 14 | copyright = "2023, Daniel Lindsley" 15 | author = "Daniel Lindsley" 16 | release = "1.2.2-alpha" 17 | 18 | # -- General configuration --------------------------------------------------- 19 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 20 | 21 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon"] 22 | 23 | templates_path = ["_templates"] 24 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 25 | 26 | 27 | # -- Options for HTML output ------------------------------------------------- 28 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 29 | 30 | html_theme = "alabaster" 31 | html_static_path = ["_static"] 32 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. django-microapi documentation master file, created by 2 | sphinx-quickstart on Thu Nov 30 16:46:53 2023. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | `django-microapi` Documentation 7 | =============================== 8 | 9 | A tiny library to make writing CBV-based APIs easier in Django. 10 | 11 | Essentially, this just provides some sugar on top of the plain old 12 | `django.views.generic.base.View` class, all with the intent of making handling 13 | JSON APIs easier (without the need for a full framework). 14 | 15 | .. toctree:: 16 | :maxdepth: 2 17 | :caption: Usage: 18 | 19 | usage/quickstart 20 | usage/tutorial 21 | usage/testing 22 | 23 | .. toctree:: 24 | :maxdepth: 2 25 | :caption: API Docs: 26 | 27 | api/views 28 | api/serializers 29 | api/tests 30 | api/http 31 | api/exceptions 32 | 33 | 34 | Indices and tables 35 | ================== 36 | 37 | * :ref:`genindex` 38 | * :ref:`modindex` 39 | * :ref:`search` 40 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | django 2 | -------------------------------------------------------------------------------- /docs/usage/quickstart.rst: -------------------------------------------------------------------------------- 1 | `django-microapi` Quick Start 2 | ============================= 3 | 4 | Installation 5 | ------------ 6 | 7 | The only requirement is Django (most recent releases should work fine). 8 | 9 | :: 10 | 11 | $ pip install django-microapi 12 | 13 | 14 | Usage 15 | ----- 16 | 17 | Assuming a relatively simple blog application, with an existing ``BlogPost`` 18 | model, we can drop the following code into a ``views.py`` (or similar):: 19 | 20 | # We pull in two useful classes from `microapi`. 21 | from microapi import ApiView, ModelSerializer 22 | 23 | from .models import BlogPost 24 | 25 | 26 | # Inherit from the `ApiView` class... 27 | class BlogPostView(ApiView): 28 | # ...then define `get`/`post`/`put`/`delete`/`patch` methods on the 29 | # subclass. 30 | 31 | # For example, we'll provide a list view on `get`. 32 | def get(self, request): 33 | posts = BlogPost.objects.all().order_by("-created") 34 | 35 | # The `render` method automatically creates a JSON response from 36 | # the provided data. 37 | return self.render({ 38 | "success": True, 39 | "posts": self.serialize_many(posts), 40 | }) 41 | 42 | # And handle creating a new blog post on `post`. 43 | def post(self, request): 44 | if not request.user.is_authenticated: 45 | return self.render_error("You must be logged in") 46 | 47 | # Read the JSON 48 | data = self.read_json(request) 49 | 50 | # TODO: Validate the data here. 51 | 52 | # Use the included `ModelSerializer` to load the user-provided data 53 | # into a new `BlogPost`. 54 | post = self.serializer.from_dict(BlogPost(), data) 55 | # Don't forget to save! 56 | post.save() 57 | 58 | return self.render({ 59 | "success": True, 60 | "post": self.serialize(post), 61 | }) 62 | 63 | Then you can hook this up in your URLconf (``urls.py``) in a familiar way:: 64 | 65 | from django.urls import path 66 | 67 | # Just import your class... 68 | from .views import BlogPostView 69 | 70 | urlpatterns = [ 71 | # ...then hook it up like any other CBV. 72 | path("api/v1/posts/", BlogPostView.as_view()), 73 | ] 74 | -------------------------------------------------------------------------------- /docs/usage/testing.rst: -------------------------------------------------------------------------------- 1 | Testing 2 | ======= 3 | 4 | ``microapi`` includes testing support as part of the package. 5 | 6 | Historically, this is one of the pain points of plain old API views in Django. 7 | The built-in test client (rightly) assumes you'll be making form posts and 8 | rendering HTML, so supporting JSON & making/checking payloads is tedious & 9 | repetitive. 10 | 11 | The testing support in `django-microapi` tries to address this. Let's start by 12 | looking at an example endpoint, and how we'd write tests for it. 13 | 14 | .. note:: If you'd prefer to look at full working code, ``microapi`` dogfoods 15 | its own testing tools. You can find them within the repository on 16 | `GitHub `_. 17 | 18 | 19 | Example Endpoint 20 | ----------------- 21 | 22 | We'll start with the similar code from the :doc:`Tutorial `:: 23 | 24 | # blog/api.py 25 | from microapi import ( 26 | ApiView, 27 | http, 28 | ) 29 | 30 | from .models import BlogPost 31 | 32 | 33 | def serialize_author(serializer, author): 34 | return { 35 | "id": author.id, 36 | "username": author.username, 37 | "email": author.email, 38 | "first_name": author.first_name, 39 | "last_name": author.last_name, 40 | } 41 | 42 | 43 | def serialize_post(serializer, post): 44 | data = serializer.to_dict(post) 45 | data["author"] = serialize_author(serializer, post.author) 46 | return data 47 | 48 | 49 | class BlogPostListView(ApiView): 50 | def serialize(self, obj): 51 | return serialize_post(obj) 52 | 53 | def get(self, request): 54 | posts = BlogPost.objects.all().order_by("-created") 55 | return self.render({ 56 | "success": True, 57 | "posts": self.serialize_many(posts), 58 | }) 59 | 60 | def post(self, request): 61 | if not request.user.is_authenticated: 62 | return self.render_error("You must be logged in") 63 | 64 | data = self.read_json(request) 65 | 66 | # TODO: Validate the data here. 67 | 68 | post = self.serializer.from_dict(BlogPost(), data) 69 | post.author = request.user 70 | post.save() 71 | 72 | return self.render({ 73 | "success": True, 74 | "post": self.serialize(post), 75 | }, status_code=http.CREATED) 76 | 77 | 78 | class BlogPostDetailView(ApiView): 79 | def serialize(self, obj): 80 | return serialize_post(obj) 81 | 82 | def get(self, request, pk): 83 | try: 84 | post = BlogPost.objects.get(pk=pk) 85 | except BlogPost.DoesNotExist: 86 | return self.render_error("Blog post not found") 87 | 88 | return self.render({ 89 | "success": True, 90 | "post": self.serialize(post), 91 | }) 92 | 93 | def put(self, request, pk): 94 | if not request.user.is_authenticated: 95 | return self.render_error("You must be logged in") 96 | 97 | data = self.read_json(request) 98 | 99 | try: 100 | post = BlogPost.objects.get(pk=pk) 101 | except BlogPost.DoesNotExist: 102 | return self.render_error("Blog post not found") 103 | 104 | post = self.serializer.from_dict(post, data) 105 | post.save() 106 | 107 | return self.render({ 108 | "success": True, 109 | "post": self.serialize(post), 110 | }, status_code=http.ACCEPTED) 111 | 112 | def delete(self, request, pk): 113 | if not request.user.is_authenticated: 114 | return self.render_error("You must be logged in") 115 | 116 | try: 117 | post = BlogPost.objects.get(pk=pk) 118 | except BlogPost.DoesNotExist: 119 | return self.render_error("Blog post not found") 120 | 121 | post.delete() 122 | 123 | return self.render({ 124 | "success": True, 125 | }, status_code=http.NO_CONTENT) 126 | 127 | 128 | Adding Tests 129 | ------------ 130 | 131 | As with most things, ``microapi`` doesn't dictate where you place your tests, 132 | but following Django's conventions is a good place to start. So we'll assume 133 | that you've made a ``blog/tests`` directory, so that you can have multiple test 134 | files within for various purposes. 135 | 136 | We'll create a new file within that directory, ``blog/tests/test_api.py``, to 137 | match our ``blog/api.py`` layout. Within that file, we'll start with the 138 | following code:: 139 | 140 | # blog/tests/test_api.py 141 | from microapi.tests import ApiTestCase 142 | 143 | from ..models import BlogPost 144 | 145 | 146 | class BlogPostListViewTestCase(ApiTestCase): 147 | def test_should_fail(self): 148 | self.fail("Ensure our tests are being run.") 149 | 150 | We're starting with a simple test class, with a single test that should fail 151 | regardless. This will help us ensure our tests are being picked up by the test 152 | runner & failing correctly. 153 | 154 | ``microapi.tests.ApiTestCase`` is a thin wrapper over the top of Django's own 155 | ``TestCase``, with additional methods to support making/receiving API requests 156 | and custom methods to assert things about the payloads or the response codes. 157 | 158 | Go run your tests as usual:: 159 | 160 | $ ./manage.py test 161 | 162 | You should get a failure (as expected):: 163 | 164 | AssertionError: Ensure our tests are being run. 165 | 166 | ---------------------------------------------------------------------- 167 | Ran 1 tests in 0.033s 168 | 169 | FAILED (failures=1) 170 | 171 | .. warning:: If you didn't get the expected failure here, something is wrong 172 | with your setup. Before writing any further tests or putting any further 173 | time into this guide, you should take the time to fix things so that your 174 | tests get picked up. 175 | 176 | Common problems/reasons include: 177 | 178 | * mis-named files/directories 179 | * an app not being included in ``INSTALLED_APPS`` 180 | * mis-naming the ``TestCase`` class within the tests 181 | 182 | Now that we're sure our tests are running, let's fix that test case & make sure 183 | the list endpoint is responding to a ``GET`` correctly:: 184 | 185 | # blog/tests/test_api.py 186 | from microapi.tests import ApiTestCase 187 | 188 | from ..models import BlogPost 189 | # We're importing our view here! 190 | from ..api import BlogPostListView 191 | 192 | 193 | class BlogPostListViewTestCase(ApiTestCase): 194 | # We're renaming this method! 195 | def test_get_success(self): 196 | # Make a test request. 197 | req = self.create_request( 198 | "/api/v1/posts/", 199 | ) 200 | # Make an API request against our view (newly-imported above). 201 | resp = self.make_request(BlogPostListView, req) 202 | # Ensure that we got an HTTP 200 OK from the endpoint. 203 | self.assertOK(resp) 204 | 205 | Nothing here is too crazy, though you'll note that we're not directly using 206 | either of Django's included ``django.test.Client``, nor the 207 | ``django.test.RequestFactory``. ``Client``, while a great tool normally, 208 | unfortunately makes a bunch of assumptions that are invalid for ``microapi``. 209 | 210 | Using ``RequestFactory`` directly is possible, but making API-related requests 211 | with it is kinda painful/repetitive, so we can do better. Enter 212 | ``ApiTestCase.create_request``, which uses ``RequestFactory`` under-the-hood. 213 | 214 | And in the same vein of trying to eliminate painful/repetitive code, 215 | ``ApiTestCase.make_request`` automates the request/response process against a 216 | given ``APIView``. It handles all the instantiation of the view, as well as 217 | performing the request against it, returning a ``HttpResponse`` in the process 218 | as normal. 219 | 220 | Finally, because HTTP status codes are more diverse & more important in an 221 | API use case, ``ApiTestCase`` ships with a host of 222 | :doc:`assertion methods <../api/tests>` that check 223 | for common RESTful status codes. In this case, we're just looking for a 224 | ``HTTP 200 OK`` from the endpoint, so ``self.assertOK(resp)`` handles that check 225 | for us. 226 | 227 | Run your tests:: 228 | 229 | $ ./manage.py test 230 | 231 | And you should get:: 232 | 233 | ---------------------------------------------------------------------- 234 | Ran 1 tests in 0.047s 235 | 236 | OK 237 | 238 | 🎉 Huzzah! Our code is running, our API is being hit, and our test is passing! 239 | 240 | ...But before we get too far ahead of ourselves, we should note that what's 241 | coming back from that endpoint right now is just an empty response: there's no 242 | data in our test database! 243 | 244 | 245 | Inspecting Responses 246 | -------------------- 247 | 248 | So that we can do more interesting things in this guide, we'll add in the 249 | creation of some basic test data in the database:: 250 | 251 | # ... 252 | 253 | class BlogPostListViewTestCase(ApiTestCase): 254 | # Adding this above the test methods. 255 | def setUp(self): 256 | super().setUp() 257 | self.user = User.objects.create_user( 258 | "testmctest", 259 | "teest@mctest.com", 260 | "testpass", 261 | ) 262 | self.post_1 = BlogPost.objects.create( 263 | title="Hello, World!", 264 | content="My first post! *SURELY*, it won't be the last...", 265 | published_by=self.user, 266 | published_on=make_aware( 267 | datetime.datetime(2023, 11, 28, 9, 26, 54, 123456), 268 | timezone=datetime.timezone.utc, 269 | ), 270 | ) 271 | self.post_2 = BlogPost.objects.create( 272 | title="Life Update", 273 | content="So, it's been awhile...", 274 | published_by=self.user, 275 | published_on=make_aware( 276 | datetime.datetime(2023, 12, 5, 10, 3, 22, 123456), 277 | timezone=datetime.timezone.utc, 278 | ), 279 | ) 280 | 281 | Running the tests should get us the same result, since we don't have any methods 282 | asserting anything about the API response(s):: 283 | 284 | ---------------------------------------------------------------------- 285 | Ran 1 tests in 0.047s 286 | 287 | OK 288 | 289 | However, we should now have actual data coming back as part of the list 290 | endpoint. So let's inspect that data & make some assertions about it:: 291 | 292 | # blog/tests/test_api.py 293 | # We're changing up the import here & adding in `check_response`! 294 | from microapi.tests import ( 295 | ApiTestCase, 296 | check_response, 297 | ) 298 | 299 | # ... 300 | 301 | class BlogPostListViewTestCase(ApiTestCase): 302 | # ... 303 | 304 | def test_get_success(self): 305 | req = self.create_request( 306 | "/api/v1/posts/", 307 | ) 308 | resp = self.make_request(BlogPostListView, req) 309 | self.assertOK(resp) 310 | 311 | # New code here! 312 | data = check_response(resp) 313 | # Here, we're just using the built-in `assert*` methods to inspect 314 | # the response data, just like asserting about any other `dict`. 315 | self.assertTrue(data["success"]) 316 | self.assertEqual(len(data["posts"]), 2) 317 | # Note that, because we're creating a stable ordering via 318 | # `.order_by("-created")`, we can count on these being in this 319 | # order. 320 | # If you have an unstable sort order, you'll need to do extra work 321 | # to make sure tests like these will consistently pass. 322 | self.assertEqual(data["posts"][0]["title"], "Life Update") 323 | self.assertEqual(data["posts"][1]["title"], "Hello, World!") 324 | 325 | The (unassuming) star of the show here is the newly-added ``check_response``. 326 | It's a utility method that takes a given ``HttpResponse``, checks for 327 | appropriate JSON headers, and will automatically decode & return the response 328 | body for you. 329 | 330 | After processing the response with ``check_response``, the data you get back is 331 | a Python representation of the JSON payload (or an empty ``dict`` if there was 332 | no payload). 333 | 334 | 335 | Testing Data-Creating Endpoints 336 | ------------------------------- 337 | 338 | Another pain-point of testing APIs is testing endpoints/methods that should 339 | create data. Forming a proper request, with the right 340 | method/headers/encoded-payload/etc., is tedious. 341 | 342 | But, using the tools we've already introduced, this gets much easier. 343 | So now we'll add on another test method to exercise the ``POST`` & create a blog 344 | post with it. 345 | 346 | We'll start by adding the new method to the same test case:: 347 | 348 | class BlogPostListViewTestCase(ApiTestCase): 349 | # ... 350 | 351 | def test_post_success(self): 352 | # While not required, I like to include a sanity-check at the 353 | # beginning of a test method, to ensure the DB is in the expected 354 | # state. 355 | # We should only have the two blog posts that are created in the 356 | # `setUp` method present. 357 | self.assertEqual(BlogPost.objects.all().count(), 2) 358 | 359 | # We'll take advantage of some of the optional arguments to 360 | # `create_request`... 361 | req = self.create_request( 362 | "/api/v1/posts/", 363 | method="post", 364 | data={ 365 | "title": "Cat Pictures", 366 | "content": "All the internet is good for.", 367 | "published_on": "2023-12-05T11:45:45.000000-0600", 368 | }, 369 | user=self.user, 370 | ) 371 | # Then make the request & check the response in a similar fashion 372 | # to the last test method. 373 | resp = self.make_request(BlogPostListView, req) 374 | # Since we expect a different status code, we use `assertCreated` 375 | # here in place of `assertOK`. 376 | self.assertCreated(resp) 377 | 378 | # Finally, a simple assertion about the state of the DB. 379 | # We should ensure the new post is present. 380 | self.assertEqual(BlogPost.objects.all().count(), 3) 381 | 382 | The only substantially different code here is how we create the request via 383 | ``ApiTestCase.create_request``. We can provide the HTTP method to use, and the 384 | data to be automatically JSON-encoded for us. 385 | 386 | Now when we run our tests, we should get back something like:: 387 | 388 | ---------------------------------------------------------------------- 389 | Ran 2 tests in 0.053s 390 | 391 | OK 392 | 393 | And we know our API is behaving properly. 394 | 395 | 396 | "Final" API Test Code 397 | --------------------- 398 | 399 | Putting everything together, our completed test code should look like:: 400 | 401 | # blog/tests/test_api.py 402 | from microapi.tests import ( 403 | ApiTestCase, 404 | check_response, 405 | ) 406 | 407 | from ..models import BlogPost 408 | from ..api import BlogPostListView 409 | 410 | class BlogPostListViewTestCase(ApiTestCase): 411 | def setUp(self): 412 | super().setUp() 413 | self.user = User.objects.create_user( 414 | "testmctest", 415 | "teest@mctest.com", 416 | "testpass", 417 | ) 418 | self.post_1 = BlogPost.objects.create( 419 | title="Hello, World!", 420 | content="My first post! *SURELY*, it won't be the last...", 421 | published_by=self.user, 422 | published_on=make_aware( 423 | datetime.datetime(2023, 11, 28, 9, 26, 54, 123456), 424 | timezone=datetime.timezone.utc, 425 | ), 426 | ) 427 | self.post_2 = BlogPost.objects.create( 428 | title="Life Update", 429 | content="So, it's been awhile...", 430 | published_by=self.user, 431 | published_on=make_aware( 432 | datetime.datetime(2023, 12, 5, 10, 3, 22, 123456), 433 | timezone=datetime.timezone.utc, 434 | ), 435 | ) 436 | 437 | def test_get_success(self): 438 | req = self.create_request( 439 | "/api/v1/posts/", 440 | ) 441 | resp = self.make_request(BlogPostListView, req) 442 | self.assertOK(resp) 443 | 444 | data = check_response(resp) 445 | self.assertTrue(data["success"]) 446 | self.assertEqual(len(data["posts"]), 2) 447 | self.assertEqual(data["posts"][0]["title"], "Life Update") 448 | self.assertEqual(data["posts"][1]["title"], "Hello, World!") 449 | 450 | def test_post_success(self): 451 | # Sanity-check. 452 | self.assertEqual(BlogPost.objects.all().count(), 2) 453 | 454 | req = self.create_request( 455 | "/api/v1/posts/", 456 | method="post", 457 | data={ 458 | "title": "Cat Pictures", 459 | "content": "All the internet is good for.", 460 | "published_on": "2023-12-05T11:45:45.000000-0600", 461 | }, 462 | user=self.user, 463 | ) 464 | resp = self.make_request(BlogPostListView, req) 465 | self.assertCreated(resp) 466 | 467 | self.assertEqual(BlogPost.objects.all().count(), 3) 468 | 469 | 470 | Pytest Support 471 | -------------- 472 | 473 | `pytest `_ is a fairly common/popular testing package 474 | within the Python community, and ``microapi`` ships with first-class support for 475 | it. 476 | 477 | ``microapi.test`` includes a host of utility functions that can be directly used 478 | within your ``pytest`` test methods to exercise API endpoints. The full list 479 | is available in the :doc:`../api/tests` reference. 480 | 481 | In fact, everything that we covered above as part of ``ApiTestCase`` *actually* 482 | uses the **function-based** utilities/assertions built for ``pytest``, neatly 483 | wrapped in a more familiar class-based approach. 484 | 485 | So we could re-write our ``blog/tests/test_api.py`` like so for ``pytest``:: 486 | 487 | # blog/tests/test_api.py 488 | # Note that our imports here are quite different! 489 | from microapi.tests import ( 490 | assert_created, 491 | assert_ok, 492 | create_request, 493 | check_response, 494 | ) 495 | 496 | from ..models import BlogPost 497 | from ..api import BlogPostListView 498 | 499 | def setup_posts(): 500 | # There are better ways to do fixtures, but for the sake of keeping 501 | # things familiar to the above code... 502 | user = User.objects.create_user( 503 | "testmctest", 504 | "teest@mctest.com", 505 | "testpass", 506 | ) 507 | post_1 = BlogPost.objects.create( 508 | title="Hello, World!", 509 | content="My first post! *SURELY*, it won't be the last...", 510 | published_by=user, 511 | published_on=make_aware( 512 | datetime.datetime(2023, 11, 28, 9, 26, 54, 123456), 513 | timezone=datetime.timezone.utc, 514 | ), 515 | ) 516 | post_2 = BlogPost.objects.create( 517 | title="Life Update", 518 | content="So, it's been awhile...", 519 | published_by=user, 520 | published_on=make_aware( 521 | datetime.datetime(2023, 12, 5, 10, 3, 22, 123456), 522 | timezone=datetime.timezone.utc, 523 | ), 524 | ) 525 | 526 | def test_posts_get_success(self): 527 | setup_posts() 528 | 529 | req = create_request( 530 | "/api/v1/posts/", 531 | ) 532 | view_func = BlogPostListView.as_view() 533 | # Don't forget to supply args/kwargs as they'd be received from the 534 | # URLconf here! 535 | resp = view_func(req) 536 | assert_ok(resp) 537 | 538 | data = check_response(resp) 539 | assert data["success"] == True 540 | assert len(data["posts"] == 2 541 | assert data["posts"][0]["title"] == "Life Update" 542 | assert data["posts"][1]["title"] == "Hello, World!" 543 | 544 | def test_post_success(self): 545 | setup_posts() 546 | 547 | # Sanity-check. 548 | assert BlogPost.objects.all().count() == 2 549 | 550 | req = create_request( 551 | "/api/v1/posts/", 552 | method="post", 553 | data={ 554 | "title": "Cat Pictures", 555 | "content": "All the internet is good for.", 556 | "published_on": "2023-12-05T11:45:45.000000-0600", 557 | }, 558 | user=user, 559 | ) 560 | view_func = BlogPostListView.as_view() 561 | # Don't forget to supply args/kwargs as they'd be received from the 562 | # URLconf here! 563 | resp = view_func(req) 564 | assert_created(resp) 565 | 566 | assert BlogPost.objects.all().count() == 3 567 | 568 | And running them with ``pytest`` should yield something like:: 569 | 570 | collected 2 items 571 | 572 | blog/tests/test_api.py .. [100%] 573 | 574 | =============== 2 passed in 0.21s =============== 575 | -------------------------------------------------------------------------------- /docs/usage/tutorial.rst: -------------------------------------------------------------------------------- 1 | Tutorial 2 | ======== 3 | 4 | A tiny library to make writing CBV-based APIs easier in Django. 5 | 6 | Essentially, this just provides some sugar on top of the plain old 7 | ``django.views.generic.base.View`` class, all with the intent of making handling 8 | JSON APIs easier (without the need for a full framework). 9 | 10 | Let's walk through adding it to an existing blogging application. 11 | 12 | 13 | Setup 14 | ----- 15 | 16 | ``django-microapi`` is easy to add to an existing project. Its only dependency 17 | is `Django `_ itself, with pretty much any modern 18 | release being supported. 19 | 20 | Installation is easy:: 21 | 22 | $ pip install django-microapi 23 | 24 | It doesn't need to be added to ``INSTALLED_APPS``, and you can just import 25 | ``microapi`` wherever you need it. 26 | 27 | .. note:: When importing, it's just ``microapi``, even though the package 28 | name is ``django-microapi``. This is done to prevent cluttering up the 29 | `PyPI `_ namespace & make it clear what the package 30 | works with. 31 | 32 | 33 | The Existing Application 34 | ------------------------ 35 | 36 | We'll assume you've implemented a relatively straight-forward blogging 37 | application. For brevity, we'll assume the ``models.py`` within the application 38 | looks something like:: 39 | 40 | # blog/models.py 41 | from django.contrib.auth import get_user_model 42 | from django.db import models 43 | from django.utils.text import slugify 44 | 45 | 46 | # Because who knows? Maybe you have a custom model... 47 | User = get_user_model() 48 | 49 | 50 | class BlogPost(models.Model): 51 | title = models.CharField(max_length=128) 52 | slug = models.SlugField(blank=True, db_index=True) 53 | author = models.ForeignKey( 54 | User, 55 | related_name="blog_posts", 56 | on_delete=models.CASCADE, 57 | ) 58 | content = models.TextField(blank=True, default="") 59 | created = models.DateTimeField( 60 | auto_now_add=True, 61 | blank=True, 62 | db_index=True, 63 | ) 64 | updated = models.DateTimeField( 65 | auto_now=True, 66 | blank=True, 67 | db_index=True, 68 | ) 69 | 70 | def __str__(self): 71 | return f"{self.title}" 72 | 73 | def save(self, *args, **kwargs): 74 | if not self.slug: 75 | self.slug = slugify(self.title) 76 | 77 | return super().save(*args, **kwargs) 78 | 79 | 80 | Your First Steps 81 | ---------------- 82 | 83 | We'll get started integrating by creating a simple list endpoint that responds 84 | to an HTTP GET. 85 | 86 | Where you put this code is up to you, as long as it's importable into your 87 | URLconf (e.g. ``blog/urls.py``). It's fine to place it in ``views.py`` if your 88 | application already has HTML-based views. 89 | 90 | Alternatively, I like to put them in a separate ``api.py`` file within the app 91 | (e.g. ``blog/api.py``), so that I'm not mixing the API & HTML code in the same 92 | file. Regardless, there's no *"wrong"* way to do it. 93 | 94 | For now, let's assume there's already stuff in ``blog/views.py``, so we'll create 95 | an empty ``blog/api.py`` file. 96 | 97 | Then we'll start with the following code:: 98 | 99 | # blog/api.py 100 | from microapi import ApiView 101 | 102 | from .models import BlogPost 103 | 104 | 105 | class BlogPostListView(ApiView): 106 | def get(self, request): 107 | posts = BlogPost.objects.all().order_by("-created") 108 | return self.render({ 109 | "success": True, 110 | "posts": self.serialize_many(posts), 111 | }) 112 | 113 | Let's step through what we've added to the file. 114 | 115 | First, we want to import the ``ApiView`` class from ``microapi``. This class 116 | builds on Django's own ``django.views.generic.base.View`` class-based view, but 117 | provides a couple additional useful bits for our use. 118 | 119 | We then create a new class, ``BlogPostListView``, and inherit from the 120 | ``ApiView`` class we imported. Like any other ``View`` subclass, we can define 121 | methods on it to handle specific HTTP verbs (e.g. 122 | ``get``, ``post``, ``put``, ``delete``, etc.). 123 | 124 | Because most RESTful APIs return a list when sending a ``GET`` to the top-level 125 | endpoint, we implement our logic in the ``BlogPostListView.get`` method:: 126 | 127 | class BlogPostListView(ApiView): 128 | # ... 129 | 130 | # We get the `HttpRequest` just like normal. 131 | # Optionally, we also accept any URLconf parameters (none in this 132 | # example). 133 | def get(self, request): 134 | # We collect a list of all the blog posts from the database via the 135 | # ORM, just like normal. 136 | posts = BlogPost.objects.all().order_by("-created") 137 | 138 | # Then we create a JSON response of all of them. 139 | return self.render({ 140 | "success": True, 141 | "posts": self.serialize_many(posts), 142 | }) 143 | 144 | The most interesting part is the call to ``self.render(...)``. Similar to 145 | Django's ``django.shortcuts.render``, this takes some data & creates a 146 | ``HttpResponse`` to serve back to the user. 147 | 148 | However, in this case, rather than rendering a template & generating HTML, this 149 | converts the data into an equivalent *JSON response*. We'll see what the result 150 | looks like after we finish hooking up the endpoint, which we'll do next. 151 | 152 | 153 | Hook Up the API Endpoint 154 | ------------------------ 155 | 156 | We've build the API endpoint, but we haven't hooked it up to a URL yet. 157 | So we'll go to our URLconf (``blog/urls.py``), and hook it in a familiar way:: 158 | 159 | # blog/urls.py 160 | from django.urls import path 161 | 162 | # Just import the new API class... 163 | from .api import BlogPostListView 164 | 165 | urlpatterns = [ 166 | # ...then hook it up like any other CBV. 167 | path("api/v1/posts/", BlogPostListView.as_view()), 168 | ] 169 | 170 | Now, assuming that's included in the main URLconf (e.g. 171 | ``path("", include("blog.urls")),``), the user can hit the endpoint in a 172 | browser & get a list of the blog posts! 173 | 174 | For example, visiting http://localhost:8000/api/v1/posts/ might yield something 175 | like:: 176 | 177 | { 178 | "success": True, 179 | "posts": [ 180 | { 181 | "id": 2, 182 | "title": "Status Update", 183 | "slug": "status-update", 184 | "content": "I just wanted to drop a quick update.", 185 | "created": "2024-01-11-T11:35:12.000-0600", 186 | "updated": "2024-01-11-T11:35:12.000-0600", 187 | }, 188 | { 189 | "id": 1, 190 | "title": "Hello, world!", 191 | "slug": "hello-world", 192 | "content": "My first post to my blog!", 193 | "created": "2024-01-09-T20:10:55.000-0600", 194 | "updated": "2024-01-09-T20:10:55.000-0600", 195 | } 196 | ] 197 | } 198 | 199 | Yay! With a relatively minimal amount of code, our first bit of API works! 200 | 201 | .. note:: You may have noticed that something (``author``) is missing from 202 | the JSON output. This is actually intentional, as ``microapi``'s take on 203 | serialization is a very simplistic one. We'll talk more about serialization 204 | next as part of the detail endpoint. 205 | 206 | 207 | Adding a Detail Endpoint 208 | ------------------------ 209 | 210 | Now that we can accept ``HTTP GET`` requests for a list endpoint, a common 211 | follow-up request is a ``HTTP GET`` **detail** endpoint. Let's add that now. 212 | 213 | As opposed to many API frameworks, ``microapi.ApiView`` is very 214 | endpoint/URL-focused. As a result of this, because the detail endpoint (e.g. 215 | ``/api/v1/posts//``) is separate/distinct from the list endpoint (e.g. 216 | ``/api/v1/posts/``), we'll need a *separate/distinct* API view to handle it. 217 | 218 | So, back within ``api.py``, we'll add a second class:: 219 | 220 | # blog/api.py 221 | from microapi import ApiView 222 | 223 | from .models import BlogPost 224 | 225 | 226 | # What we previously defined. 227 | class BlogPostListView(ApiView): 228 | def get(self, request): 229 | posts = BlogPost.objects.all().order_by("-created") 230 | return self.render({ 231 | "success": True, 232 | "posts": self.serialize_many(posts), 233 | }) 234 | 235 | 236 | # Here's where the new code is! 237 | # Note that while similar, this is a different name from above. 238 | class BlogPostDetailView(ApiView): 239 | # ...and this method signature is different! 240 | def get(self, request, pk): 241 | try: 242 | post = BlogPost.objects.get(pk=pk) 243 | except BlogPost.DoesNotExist: 244 | return self.render_error("Blog post not found") 245 | 246 | return self.render({ 247 | "success": True, 248 | "post": self.serialize(post), 249 | }) 250 | 251 | Before we forget, let's hook up the new endpoint, then we'll talk about how this 252 | new endpoint is different:: 253 | 254 | # blog/urls.py 255 | from django.urls import path 256 | 257 | # Import both classes. 258 | from .api import ( 259 | BlogPostListView, 260 | BlogPostDetailView, 261 | ) 262 | 263 | urlpatterns = [ 264 | # The previously added list endpoint... 265 | path("api/v1/posts/", BlogPostListView.as_view()), 266 | # ...and the new detail endpoint! 267 | path("api/v1/posts//", BlogPostDetailView.as_view()), 268 | ] 269 | 270 | While this new code is very similar to the list endpoint, there are a couple 271 | key differences to talk about: 272 | 273 | * Different ``get(...)`` signature 274 | * Catching a failed lookup & returning an error with ``render_error`` 275 | * The use of ``serialize(...)`` instead of ``serialize_many(...)`` 276 | 277 | Different ``get(...)`` Signature 278 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 279 | 280 | The different method signature on ``BlogPostDetailView.get(...)`` comes down to 281 | the addition of a new parameter, ``pk``. 282 | 283 | Like a normal Django function-based (or class-based) view, we can accept 284 | additional parameters *from the URLconf*. In this case, the URLconf captures the 285 | blog post's primary key as ``pk``, which then gets passed along for use to the 286 | view method. 287 | 288 | .. note:: ``microapi`` doesn't enforce any constraints on captures, so 289 | you can capture/use as many parameters as you want from a URLconf. 290 | This can be great for things like nested endpoints, or supporting more 291 | complicated URLs. 292 | 293 | Catching a Failed Lookup & Returning an Error 294 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 295 | 296 | Let's turn our attention to the lookup/fetch of the post:: 297 | 298 | try: 299 | post = BlogPost.objects.get(pk=pk) 300 | except BlogPost.DoesNotExist: 301 | return self.render_error("Blog post not found") 302 | 303 | One of the niceties built into ``microapi`` is the ability to handle/return 304 | errors in an API-friendly way. 305 | 306 | Normally when Django encounters an error, depending on the value of 307 | ``settings.DEBUG``, it'll either return an *HTML* debug page or a rendered 308 | *HTML* error page. When you're working with an API client, neither of those 309 | options are particularly friendly/natural, especially if you need to extract 310 | information to present to the user. 311 | 312 | ``microapi`` improves on this by intercepting errors, and rendering a 313 | *JSON-based* API error response instead! If the lookup fails, the user will get 314 | an error like:: 315 | 316 | { 317 | "success": False, 318 | "errors": [ 319 | "Blog post not found" 320 | ] 321 | } 322 | 323 | Even if we hadn't explicitly added ``try/except`` handling, under the hood, 324 | ``microapi`` would've rendered a similar error including the exception message. 325 | 326 | You can manually call ``self.render_error(...)`` as many times as you want in 327 | your view code, and you can supply either a single error string, or a list of 328 | error strings! This can be great for validation situations, or when multiple 329 | conditions failed. 330 | 331 | The Use of ``serialize(...)`` 332 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 333 | 334 | The final notable change is the call to ``self.serialize(post)``. 335 | 336 | Previously, in the list endpoint, we just quietly called 337 | ``self.serialize_many(posts)`` & didn't really talk about serialization, letting 338 | ``microapi`` just handle things for us. 339 | 340 | The rule of thumb here is to call ``serialize(model_obj)`` when it's a *single* 341 | instance, and calling ``serialize_many(queryset_or_list)`` when it's a 342 | *collection* of instances to serialize. 343 | 344 | .. note:: ``serialize_many(...)`` just iterates & makes calls to 345 | ``serialize(...)``. So you can customize just the detail serialization in 346 | ``serialize``, & the list version coming from ``serialize_many`` will stay 347 | in-sync. 348 | 349 | This all leads us to a slight tangent: serialization in general. 350 | 351 | 352 | Tangent: Serialization 353 | ---------------------- 354 | 355 | When it comes to the format of data entering/leaving an API, there are a wide 356 | range of viewpoints. Some people subscribe to a minimalist view, meaning 357 | returning a small/flat structure of the data (which can lead to many simple 358 | requests). Others prefer deep/rich structures, including related data structures 359 | (a single request with a large/complex response). Still others want follow 360 | things like `HATEOAS `_ & return URLs 361 | to resources instead of PKs or nested structures. 362 | 363 | To combat assumptions (& honestly complex feature bloat), ``microapi`` takes a 364 | simplistic approach to the default serialization, then makes it easy to 365 | extend/override serialization to meet your needs. 366 | 367 | By default, ``microapi`` includes a ``ModelSerializer``, which we've been 368 | conveniently/quietly using via ``ApiView.serialize(...)`` / 369 | ``ApiView.serialize_many(...)``. 370 | 371 | ``ModelSerializer`` will accept a model instance, collect all **concrete** 372 | fields, and return a dictionary representation of that data. It will **NOT** 373 | collect/return: 374 | 375 | * related fields/data 376 | * generated fields 377 | * virtual fields 378 | 379 | While this is limited by default, this prevents excessively leaning on PKs or 380 | too-deeply-nested situations, as well as a whole host of edge-cases. It's also 381 | easily extended, as we're about to see. 382 | 383 | 384 | Adding Author Information 385 | ------------------------- 386 | 387 | Let's change things so that the author information is included in the API. For 388 | our uses, since ``author`` is a single related object that will always be 389 | present, we want to include a nested representation of it:: 390 | 391 | # blog/api.py 392 | from microapi import ApiView 393 | 394 | from .models import BlogPost 395 | 396 | 397 | class BlogPostListView(ApiView): 398 | def get(self, request): 399 | posts = BlogPost.objects.all().order_by("-created") 400 | return self.render({ 401 | "success": True, 402 | "posts": self.serialize_many(posts), 403 | }) 404 | 405 | 406 | class BlogPostDetailView(ApiView): 407 | # We're adding code here! 408 | def serialize(self, obj): 409 | data = super().serialize(obj) 410 | data["author"] = self.serializer.to_dict(obj.author) 411 | return data 412 | 413 | def get(self, request, pk): 414 | try: 415 | post = BlogPost.objects.get(pk=pk) 416 | except BlogPost.DoesNotExist: 417 | return self.render_error("Blog post not found") 418 | 419 | return self.render({ 420 | "success": True, 421 | "post": self.serialize(post), 422 | }) 423 | 424 | To start with, we override the ``BlogPostDetailView.serialize(...)`` method. 425 | We'll call ``super().serialize(...)`` to get the default data from ``microapi``. 426 | 427 | Then we embelish the resulting ``dict`` to add the ``author`` key, and use the 428 | ``self.serializer.to_dict(...)`` to give us a serialized version of the related 429 | ``User`` object. Then finally we return the newly-serialized data. 430 | 431 | In this way, we retain strong control over how we represent data in our API, 432 | while trying to keep the implementation as clean/simple as possible. 433 | 434 | Now when the user requests something like 435 | https://localhost:8000/api/v1/posts/1/, they get:: 436 | 437 | { 438 | "success": True, 439 | "post": { 440 | "id": 1, 441 | "title": "Hello, world!", 442 | "slug": "hello-world", 443 | "author": { 444 | "id": 1, 445 | "username": "daniel", 446 | "email": "daniel@toastdriven.com", 447 | "first_name": "Daniel", 448 | "last_name": "", 449 | "password": "OMG_REDACTED_THIS_IS_SUPER_BAD", 450 | "is_superuser": True, 451 | "is_staff": True, 452 | "is_active": True, 453 | "date_joined": "2023-12-19-T11:03:19.000-0600", 454 | "last_login": "2024-01-09-T20:10:55.000-0600" 455 | }, 456 | "content": "My first post to my blog!", 457 | "created": "2024-01-09-T20:10:55.000-0600", 458 | "updated": "2024-01-09-T20:10:55.000-0600", 459 | } 460 | } 461 | 462 | While this is a substantial improvement, we've got a **BIG** problem: because 463 | we're naively serializing ``User``, we're **leaking** private user information! 464 | Things like ``password``, ``is_superuser``, ``is_staff``, ``last_login`` 465 | shouldn't be generally be included in an API! 466 | 467 | Fortunately, this is easy to remedy:: 468 | 469 | class BlogPostDetailView(ApiView): 470 | def serialize(self, obj): 471 | data = super().serialize(obj) 472 | # We're changing up this line. 473 | data["author"] = self.serializer.to_dict( 474 | obj.author, 475 | # We can supply `exclude` here & provide a list of fields that 476 | # should not be included in the serialized representation. 477 | exclude=[ 478 | "password", 479 | "is_superuser", 480 | "is_staff", 481 | "date_joined", 482 | "last_login", 483 | ] 484 | ) 485 | return data 486 | 487 | Refresh https://localhost:8000/api/v1/posts/1/, and now the user gets a 488 | much-safer & more reasonable set of data:: 489 | 490 | { 491 | "success": True, 492 | "post": { 493 | "id": 1, 494 | "title": "Hello, world!", 495 | "slug": "hello-world", 496 | "author": { 497 | "id": 1, 498 | "username": "daniel", 499 | "email": "daniel@toastdriven.com", 500 | "first_name": "Daniel", 501 | "last_name": "" 502 | }, 503 | "content": "My first post to my blog!", 504 | "created": "2024-01-09-T20:10:55.000-0600", 505 | "updated": "2024-01-09-T20:10:55.000-0600", 506 | } 507 | } 508 | 509 | Finally, let's say we want this author information in the list view as well. 510 | And because we've got a custom ``User`` model, we want to play it safe & show 511 | only an approved list of fields:: 512 | 513 | # blog/api.py 514 | from microapi import ApiView 515 | 516 | from .models import BlogPost 517 | 518 | 519 | # New code starts here! 520 | def serialize_author(serializer, author): 521 | # Rather than lean on the serializer, there's nothing stopping us from 522 | # just constructing our own dict of data. 523 | # In this case, should new fields get added in the future, this prevents 524 | # potentially-sensitive leaks of data. 525 | return { 526 | "id": author.id, 527 | "username": author.username, 528 | "email": author.email, 529 | "first_name": author.first_name, 530 | "last_name": author.last_name, 531 | } 532 | 533 | 534 | # No need to define a custom class or anything. Any callable that returns 535 | # JSON-serializable data is good enough. 536 | def serialize_post(serializer, post): 537 | data = serializer.to_dict(post) 538 | data["author"] = serialize_author(serializer, post.author) 539 | return data 540 | 541 | 542 | class BlogPostListView(ApiView): 543 | # Newly overridden! 544 | def serialize(self, obj): 545 | return serialize_post(obj) 546 | 547 | def get(self, request): 548 | posts = BlogPost.objects.all().order_by("-created") 549 | return self.render({ 550 | "success": True, 551 | "posts": self.serialize_many(posts), 552 | }) 553 | 554 | 555 | class BlogPostDetailView(ApiView): 556 | # Replacing our old overridden code! 557 | def serialize(self, obj): 558 | return serialize_post(obj) 559 | 560 | def get(self, request, pk): 561 | try: 562 | post = BlogPost.objects.get(pk=pk) 563 | except BlogPost.DoesNotExist: 564 | return self.render_error("Blog post not found") 565 | 566 | return self.render({ 567 | "success": True, 568 | "post": self.serialize(post), 569 | }) 570 | 571 | Now our list & detail endpoints have matching data, and we 572 | :abbr:`DRY (Don't Repeat Yourself)`'ed up the code to create a single way we 573 | serialize both authors & posts. 574 | 575 | 576 | Creating Data 577 | ------------- 578 | 579 | Up until now, we've been only working on a read-only version of the API that 580 | solely responds to ``HTTP GET``. But it'd be nice to be able to *create* new 581 | blog posts via the API. 582 | 583 | In most RESTful applications, the expected way to handle creating new data is to 584 | perform an ``HTTP POST`` to the *list* endpoint, so let's add that:: 585 | 586 | # blog/api.py 587 | # This import changed! 588 | from microapi import ( 589 | ApiView, 590 | http, 591 | ) 592 | 593 | from .models import BlogPost 594 | 595 | 596 | # Omitting serialization for readability. 597 | # ... 598 | # Leave it there! 599 | 600 | 601 | class BlogPostListView(ApiView): 602 | def serialize(self, obj): 603 | return serialize_post(obj) 604 | 605 | def get(self, request): 606 | posts = BlogPost.objects.all().order_by("-created") 607 | return self.render({ 608 | "success": True, 609 | "posts": self.serialize_many(posts), 610 | }) 611 | 612 | # Here's the newly added code! 613 | def post(self, request): 614 | if not request.user.is_authenticated: 615 | return self.render_error("You must be logged in") 616 | 617 | data = self.read_json(request) 618 | 619 | # TODO: Validate the data here. 620 | 621 | post = self.serializer.from_dict(BlogPost(), data) 622 | post.author = request.user 623 | post.save() 624 | 625 | return self.render({ 626 | "success": True, 627 | "post": self.serialize(post), 628 | }, status_code=http.CREATED) 629 | 630 | We've added a new ``post`` method to ``BlogPostListView``. The other new bits 631 | here are the use of ``read_json(request)`` & ``serializer.from_dict(...)``. 632 | 633 | ``microapi`` includes an ``ApiView.read_json(request)`` method, which makes it 634 | easy to extract a JSON payload from a request body. This is similar to how 635 | you might use ``request.POST`` in regular application code. 636 | 637 | The other noteworthy code, 638 | ``post = self.serializer.from_dict(BlogPost(), data)``, is a bit more involved, 639 | so let's deconstruct what's going on. 640 | 641 | ``ModelSerializer`` includes a ``from_dict(model_obj, data)`` method, which 642 | takes a ``dict`` of data & tries to assign the values to fields on a ``Model`` 643 | instance. Since we've already grabbed the request ``data`` fromthe JSON, and 644 | we're creating a new model object (``BlogPost()``), we can just hand those two 645 | off to ``self.serializer.from_dict(BlogPost(), data)`` & it will populate that 646 | fresh model instance for us. 647 | 648 | Assign on the ``author`` to the user that ``POST``'ed the data, remember to 649 | **save**, and then we return a success message with the newly-created data. We 650 | can supply the ``http.CREATED`` status code to ensure the resulting response has 651 | a ``201 Created`` HTTP status code associated with it! 652 | 653 | 654 | Updating & Deleting Data 655 | ------------------------ 656 | 657 | Finally, let's add updating & deleting data. These are pretty straight-forward 658 | & largely just combine things we've already seen:: 659 | 660 | class BlogPostDetailView(ApiView): 661 | def serialize(self, obj): 662 | return serialize_post(obj) 663 | 664 | def get(self, request, pk): 665 | try: 666 | post = BlogPost.objects.get(pk=pk) 667 | except BlogPost.DoesNotExist: 668 | return self.render_error("Blog post not found") 669 | 670 | return self.render({ 671 | "success": True, 672 | "post": self.serialize(post), 673 | }) 674 | 675 | # New code starts here! 676 | def put(self, request, pk): 677 | if not request.user.is_authenticated: 678 | return self.render_error("You must be logged in") 679 | 680 | data = self.read_json(request) 681 | 682 | try: 683 | post = BlogPost.objects.get(pk=pk) 684 | except BlogPost.DoesNotExist: 685 | return self.render_error("Blog post not found") 686 | 687 | post = self.serializer.from_dict(post, data) 688 | post.save() 689 | 690 | return self.render({ 691 | "success": True, 692 | "post": self.serialize(post), 693 | }, status_code=http.ACCEPTED) 694 | 695 | def delete(self, request, pk): 696 | if not request.user.is_authenticated: 697 | return self.render_error("You must be logged in") 698 | 699 | try: 700 | post = BlogPost.objects.get(pk=pk) 701 | except BlogPost.DoesNotExist: 702 | return self.render_error("Blog post not found") 703 | 704 | post.delete() 705 | 706 | return self.render({ 707 | "success": True, 708 | }, status_code=http.NO_CONTENT) 709 | 710 | We add a ``put`` method to handle updating an existing object via ``HTTP PUT`` 711 | to the detail endpoint, and a ``delete`` method to handle deleting an existing 712 | object via ``HTTP DELETE``. 713 | 714 | Both lookup/fetch the post as we have before. For the update, we read the JSON 715 | payload from the ``request`` body & update the object just like in the ``POST`` 716 | example. And for the delete, all we need to do is delete the model via the ORM. 717 | 718 | 719 | Final Code 720 | ---------- 721 | 722 | When we've finished, our final API code should look like:: 723 | 724 | # blog/api.py 725 | from microapi import ( 726 | ApiView, 727 | http, 728 | ) 729 | 730 | from .models import BlogPost 731 | 732 | 733 | def serialize_author(serializer, author): 734 | return { 735 | "id": author.id, 736 | "username": author.username, 737 | "email": author.email, 738 | "first_name": author.first_name, 739 | "last_name": author.last_name, 740 | } 741 | 742 | 743 | def serialize_post(serializer, post): 744 | data = serializer.to_dict(post) 745 | data["author"] = serialize_author(serializer, post.author) 746 | return data 747 | 748 | 749 | class BlogPostListView(ApiView): 750 | def serialize(self, obj): 751 | return serialize_post(obj) 752 | 753 | def get(self, request): 754 | posts = BlogPost.objects.all().order_by("-created") 755 | return self.render({ 756 | "success": True, 757 | "posts": self.serialize_many(posts), 758 | }) 759 | 760 | def post(self, request): 761 | if not request.user.is_authenticated: 762 | return self.render_error("You must be logged in") 763 | 764 | data = self.read_json(request) 765 | 766 | # TODO: Validate the data here. 767 | 768 | post = self.serializer.from_dict(BlogPost(), data) 769 | post.author = request.user 770 | post.save() 771 | 772 | return self.render({ 773 | "success": True, 774 | "post": self.serialize(post), 775 | }, status_code=http.CREATED) 776 | 777 | 778 | class BlogPostDetailView(ApiView): 779 | def serialize(self, obj): 780 | return serialize_post(obj) 781 | 782 | def get(self, request, pk): 783 | try: 784 | post = BlogPost.objects.get(pk=pk) 785 | except BlogPost.DoesNotExist: 786 | return self.render_error("Blog post not found") 787 | 788 | return self.render({ 789 | "success": True, 790 | "post": self.serialize(post), 791 | }) 792 | 793 | def put(self, request, pk): 794 | if not request.user.is_authenticated: 795 | return self.render_error("You must be logged in") 796 | 797 | data = self.read_json(request) 798 | 799 | try: 800 | post = BlogPost.objects.get(pk=pk) 801 | except BlogPost.DoesNotExist: 802 | return self.render_error("Blog post not found") 803 | 804 | post = self.serializer.from_dict(post, data) 805 | post.save() 806 | 807 | return self.render({ 808 | "success": True, 809 | "post": self.serialize(post), 810 | }, status_code=http.ACCEPTED) 811 | 812 | def delete(self, request, pk): 813 | if not request.user.is_authenticated: 814 | return self.render_error("You must be logged in") 815 | 816 | try: 817 | post = BlogPost.objects.get(pk=pk) 818 | except BlogPost.DoesNotExist: 819 | return self.render_error("Blog post not found") 820 | 821 | post.delete() 822 | 823 | return self.render({ 824 | "success": True, 825 | }, status_code=http.NO_CONTENT) 826 | 827 | And with that, plus the two URLconfs you added long ago, you have a RESTful API 828 | for inspecting/managing blog posts in your application! 🎉 829 | 830 | 831 | Next Steps 832 | ---------- 833 | 834 | This represents ~90%+ of the daily usage of ``microapi``, but the library 835 | does include a handful of other tools/utilities to make crafting APIs 836 | easier. Information on these can be found in the other Usage guides or the 837 | Api docs. 838 | 839 | Enjoy & happy API creation! 840 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # https://just.systems/ 2 | set dotenv-load := false 3 | 4 | @_default: 5 | just --list 6 | 7 | @build: 8 | pipenv run python3 -m build 9 | 10 | @pypi: 11 | pipenv run twine upload dist/* 12 | 13 | @publish: build pypi 14 | 15 | @docs: 16 | cd docs && make html 17 | 18 | @test: 19 | pipenv install 20 | pipenv install -e . 21 | pipenv run bash -c "cd test && ./manage.py test" 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "django-microapi" 3 | version = "1.2.2-alpha" 4 | description = "A tiny library to make writing CBV-based APIs easier in Django." 5 | authors = [ 6 | {name = "Daniel Lindsley", email = "daniel@toastdriven.com"}, 7 | ] 8 | license = {text = "BSD-3-Clause"} 9 | readme = "README.md" 10 | requires-python = ">=3.7" 11 | dependencies = [ 12 | "django", 13 | ] 14 | keywords = [ 15 | "django", 16 | "json", 17 | "api", 18 | ] 19 | classifiers = [ 20 | "Framework :: Django", 21 | "Programming Language :: Python :: 3", 22 | "License :: OSI Approved :: BSD License", 23 | "Operating System :: OS Independent", 24 | ] 25 | 26 | [project.urls] 27 | Repository = "https://github.com/toastdriven/django-microapi.git" 28 | Homepage = "https://github.com/toastdriven/django-microapi" 29 | Issues = "https://github.com/toastdriven/django-microapi/issues" 30 | 31 | [tool.setuptools.packages.find] 32 | where = ["src"] 33 | include = ["microapi*"] 34 | # exclude = ["tests*"] 35 | 36 | [build-system] 37 | requires = ["setuptools"] 38 | build-backend = "setuptools.build_meta" 39 | -------------------------------------------------------------------------------- /src/microapi/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | django-microapi 3 | =============== 4 | 5 | A tiny library to make writing CBV-based APIs easier in Django. 6 | 7 | Essentially, this just provides some sugar on top of the plain old 8 | `django.views.generic.base.View` class, all with the intent of making handling 9 | JSON APIs easier (without the need for a full framework). 10 | """ 11 | from .exceptions import ( 12 | ApiError, 13 | InvalidFieldError, 14 | DataValidationError, 15 | ) 16 | from .serializers import ModelSerializer 17 | from .views import ApiView 18 | 19 | 20 | __author__ = "Daniel Lindsley" 21 | __license__ = "New BSD" 22 | __version__ = "1.2.2-alpha" 23 | 24 | # For convenience... 25 | __ALL__ = [ 26 | ApiView, 27 | ModelSerializer, 28 | ApiError, 29 | InvalidFieldError, 30 | DataValidationError, 31 | ] 32 | -------------------------------------------------------------------------------- /src/microapi/exceptions.py: -------------------------------------------------------------------------------- 1 | class ApiError(Exception): 2 | """ 3 | A generic base exception for all exceptions to inherit from. 4 | 5 | This makes handling all exceptions raised by this library easier to catch. 6 | """ 7 | 8 | pass 9 | 10 | 11 | class DataValidationError(ApiError): 12 | """ 13 | An exception raised when invalid data is provided by the user. 14 | """ 15 | 16 | pass 17 | 18 | 19 | class InvalidFieldError(ApiError): 20 | """ 21 | Raised by deserialization when unexpected (non-Model) data is provided by 22 | the user. 23 | """ 24 | 25 | pass 26 | -------------------------------------------------------------------------------- /src/microapi/http.py: -------------------------------------------------------------------------------- 1 | OK = 200 2 | CREATED = 201 3 | ACCEPTED = 202 4 | NO_CONTENT = 204 5 | BAD_REQUEST = 400 6 | UNAUTHORIZED = 401 7 | FORBIDDEN = 403 8 | NOT_FOUND = 404 9 | NOT_ALLOWED = 405 10 | IM_A_TEAPOT = 418 11 | APP_ERROR = 500 12 | -------------------------------------------------------------------------------- /src/microapi/serializers.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | from django.db.models.fields.related import RelatedField 4 | 5 | from .exceptions import InvalidFieldError 6 | 7 | 8 | class ModelSerializer: 9 | """ 10 | A stupid-simple object to handle serializing/deserializing Model data. 11 | 12 | If you have even the slightest of complex needs, you're better off handling 13 | serialization manually. 14 | """ 15 | 16 | def collect_field_names(self, model): 17 | """ 18 | Given a Model instance, collects all the field names from it. 19 | 20 | This includes any parent fields. It excludes: 21 | * Related fields 22 | * Generated fields 23 | * Virtual fields 24 | 25 | Args: 26 | model (Model): A Django Model instance (not the class). 27 | 28 | Returns: 29 | list: A sorted list of all field names. 30 | """ 31 | field_names = set() 32 | 33 | for field in model._meta.fields: 34 | # Exclude exceptional (Related, Generated, Virtual) fields. 35 | if field.column is None or getattr(field, "generated", False): 36 | continue 37 | 38 | if isinstance(field, RelatedField): 39 | continue 40 | 41 | field_names.add(field.attname) 42 | 43 | # Sort the field list in alphabetical order. 44 | # While not strictly necessary, we're talking about a relatively small 45 | # number of fields, and nicer readability for consumers. 46 | return sorted(field_names) 47 | 48 | def to_dict(self, model, exclude=None): 49 | """ 50 | Converts a populated Model's fields/values into a plain dictionary. 51 | 52 | Args: 53 | model (Model): A populated Model instance. 54 | exclude (list): A list of fields to exclude from the final dict. 55 | Default is `[]`. 56 | 57 | Returns: 58 | OrderedDict: A dictionary of the field names/values. 59 | """ 60 | if exclude is None: 61 | exclude = [] 62 | 63 | # Use an ordered dict, as it'll preserve the sorted field order when 64 | # it's time to serialize the JSON. 65 | data = OrderedDict() 66 | field_list = self.collect_field_names(model) 67 | 68 | for field_name in field_list: 69 | data[field_name] = getattr(model, field_name) 70 | 71 | for exclude_name in exclude: 72 | data.pop(exclude_name, None) 73 | 74 | return data 75 | 76 | def from_dict(self, model, data, strict=False): 77 | """ 78 | Loads data from a dictionary onto a Model instance. 79 | 80 | If there are keys in the data that you do **NOT** want populated on the 81 | Model (such as primary keys, "private" fields, etc.), you should `pop` 82 | those values before passing to this method. 83 | 84 | Args: 85 | model (Model): A Model instance. 86 | data (dict): The data provided by a user. 87 | strict (bool): (Optional) If `True`, requires all keys in the data 88 | to match to an existing Model field. Default is `False`. 89 | 90 | Returns: 91 | Model: The populated (but unsaved) model instance. 92 | """ 93 | field_list = self.collect_field_names(model) 94 | 95 | for key, value in data.items(): 96 | if strict and key not in field_list: 97 | raise InvalidFieldError( 98 | f"{key} not found on {model.__class__.__name__}" 99 | ) 100 | 101 | setattr(model, key, value) 102 | 103 | return model 104 | -------------------------------------------------------------------------------- /src/microapi/tests.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.test import ( 4 | RequestFactory, 5 | TestCase, 6 | ) 7 | from django.test.client import MULTIPART_CONTENT 8 | 9 | from . import http 10 | 11 | 12 | # Some assert methods, useful for `pytest` or similar. 13 | def assert_status_code(resp, status_code): 14 | """ 15 | Checks the response for a specific status code. 16 | 17 | There are many specialized variants included with this library, so this 18 | is really only needed when you need to support a rarer HTTP status code. 19 | 20 | Args: 21 | resp (django.http.HttpResponse): The response from the view. 22 | status_code (int): The desired HTTP status code. 23 | 24 | Raises: 25 | AssertionError: If the expected status code does not match the response. 26 | """ 27 | # We try/except here, so that we can add more details when used with the 28 | # Django `TestCase` below. 29 | try: 30 | assert resp.status_code == status_code 31 | except AssertionError: 32 | raise AssertionError(f"{resp.status_code} != {status_code}") 33 | 34 | 35 | def assert_ok(resp): 36 | """ 37 | Checks the response for an `HTTP 200 OK`. 38 | 39 | Args: 40 | resp (django.http.HttpResponse): The response from the view. 41 | 42 | Raises: 43 | AssertionError: If the response's status code is not a `200`. 44 | """ 45 | assert_status_code(resp, status_code=http.OK) 46 | 47 | 48 | def assert_created(resp): 49 | """ 50 | Checks the response for an `HTTP 201 Created`. 51 | 52 | Args: 53 | resp (django.http.HttpResponse): The response from the view. 54 | 55 | Raises: 56 | AssertionError: If the response's status code is not a `201`. 57 | """ 58 | assert_status_code(resp, status_code=http.CREATED) 59 | 60 | 61 | def assert_accepted(resp): 62 | """ 63 | Checks the response for an `HTTP 202 Accepted`. 64 | 65 | Args: 66 | resp (django.http.HttpResponse): The response from the view. 67 | 68 | Raises: 69 | AssertionError: If the response's status code is not a `202`. 70 | """ 71 | assert_status_code(resp, status_code=http.ACCEPTED) 72 | 73 | 74 | def assert_no_content(resp): 75 | """ 76 | Checks the response for an `HTTP 204 No Content`. 77 | 78 | Args: 79 | resp (django.http.HttpResponse): The response from the view. 80 | 81 | Raises: 82 | AssertionError: If the response's status code is not a `204`. 83 | """ 84 | assert_status_code(resp, status_code=http.NO_CONTENT) 85 | 86 | 87 | def assert_bad_request(resp): 88 | """ 89 | Checks the response for an `HTTP 400 Bad Request`. 90 | 91 | Args: 92 | resp (django.http.HttpResponse): The response from the view. 93 | 94 | Raises: 95 | AssertionError: If the response's status code is not a `400`. 96 | """ 97 | assert_status_code(resp, status_code=http.BAD_REQUEST) 98 | 99 | 100 | def assert_unauthorized(resp): 101 | """ 102 | Checks the response for an `HTTP 401 Unauthorized`. 103 | 104 | Args: 105 | resp (django.http.HttpResponse): The response from the view. 106 | 107 | Raises: 108 | AssertionError: If the response's status code is not a `401`. 109 | """ 110 | assert_status_code(resp, status_code=http.UNAUTHORIZED) 111 | 112 | 113 | def assert_forbidden(resp): 114 | """ 115 | Checks the response for an `HTTP 403 Forbidden`. 116 | 117 | Args: 118 | resp (django.http.HttpResponse): The response from the view. 119 | 120 | Raises: 121 | AssertionError: If the response's status code is not a `403`. 122 | """ 123 | assert_status_code(resp, status_code=http.FORBIDDEN) 124 | 125 | 126 | def assert_not_found(resp): 127 | """ 128 | Checks the response for an `HTTP 404 Not Found`. 129 | 130 | Args: 131 | resp (django.http.HttpResponse): The response from the view. 132 | 133 | Raises: 134 | AssertionError: If the response's status code is not a `404`. 135 | """ 136 | assert_status_code(resp, status_code=http.NOT_FOUND) 137 | 138 | 139 | def assert_not_allowed(resp): 140 | """ 141 | Checks the response for an `HTTP 405 Not Allowed`. 142 | 143 | Args: 144 | resp (django.http.HttpResponse): The response from the view. 145 | 146 | Raises: 147 | AssertionError: If the response's status code is not a `405`. 148 | """ 149 | assert_status_code(resp, status_code=http.NOT_ALLOWED) 150 | 151 | 152 | def assert_app_error(resp): 153 | """ 154 | Checks the response for an `HTTP 500 Application Error`. 155 | 156 | Args: 157 | resp (django.http.HttpResponse): The response from the view. 158 | 159 | Raises: 160 | AssertionError: If the response's status code is not a `500`. 161 | """ 162 | assert_status_code(resp, status_code=http.APP_ERROR) 163 | 164 | 165 | def check_response(resp): 166 | """ 167 | Checks for a valid response & returns the decoded JSON data. 168 | 169 | This checks for: 170 | * A valid `Content-Type` header 171 | * Loads the JSON body 172 | 173 | If no body is present, this returns an empty `dict`. 174 | 175 | Args: 176 | resp (django.http.HttpResponse): The response from the view. 177 | 178 | Returns: 179 | dict: The loaded data, if any. 180 | 181 | Raises: 182 | AssertionError: If the `Content-Type` header doesn't contain the JSON 183 | header. 184 | ValueError: If a body is present, but is not valid JSON. 185 | """ 186 | assert "application/json" in resp.headers.get("Content-Type", "") 187 | 188 | body_data = resp.content 189 | 190 | if len(body_data): 191 | return json.loads(body_data) 192 | 193 | return {} 194 | 195 | 196 | def create_request(url, method="GET", headers=None, data=None, user=None, factory=None): 197 | """ 198 | Creates a `Request` object (via a `django.test.RequestFactory`). 199 | 200 | Args: 201 | url (str): The URL as entered by the user. 202 | method (str): The HTTP method used. Case-insensitive. Default is `GET`. 203 | headers (dict): The HTTP headers on the request. Default is `None`, 204 | which turns into basic JSON headers. 205 | data (dict): The JSON data to send. Default is `None`. 206 | user (User): (Optional) Logged in user. Default is `None`. 207 | factory (RequestFactory): (Optional) Allows for providing a different 208 | `RequestFactory`. Default is `django.test.RequestFactory`. 209 | 210 | Returns: 211 | Request: The built request object. 212 | """ 213 | if headers is None: 214 | headers = { 215 | "Content-Type": "application/json", 216 | "Accepts": "application/json", 217 | } 218 | 219 | if factory is None: 220 | factory = RequestFactory() 221 | 222 | if method.lower() == "get": 223 | req_method = factory.get 224 | elif method.lower() == "post": 225 | req_method = factory.post 226 | elif method.lower() == "put": 227 | req_method = factory.put 228 | elif method.lower() == "delete": 229 | req_method = factory.delete 230 | elif method.lower() == "patch": 231 | raise ValueError("Django's RequestFactory does not support PATCH.") 232 | elif method.lower() == "head": 233 | req_method = factory.head 234 | elif method.lower() == "options": 235 | req_method = factory.options 236 | elif method.lower() == "trace": 237 | req_method = factory.trace 238 | 239 | req = req_method( 240 | url, 241 | data=data, 242 | content_type=headers.get("Content-Type", MULTIPART_CONTENT), 243 | headers=headers, 244 | ) 245 | 246 | if user is not None: 247 | req.user = user 248 | 249 | return req 250 | 251 | 252 | class ApiTestCase(TestCase): 253 | """ 254 | A lightly customized `TestCase` that provide convenience methods for 255 | making API requests & checking API responses. 256 | 257 | This does not do anything automatically (beyond creating a `self.factory` 258 | for making requests). 259 | """ 260 | 261 | def setUp(self): 262 | super().setUp() 263 | self.factory = RequestFactory() 264 | 265 | def create_request(self, url, method="GET", headers=None, data=None, user=None): 266 | """ 267 | Creates a `Request` object. 268 | 269 | Args: 270 | url (str): The URL as entered by the user. 271 | method (str): The HTTP method used. Case-insensitive. 272 | Default is `GET`. 273 | headers (dict): The HTTP headers on the request. Default is `None`, 274 | which turns into basic JSON headers. 275 | data (dict): The JSON data to send. Default is `None`. 276 | user (User): (Optional) Logged in user. Default is `None`. 277 | 278 | Returns: 279 | Response: The received response object from calling the view. 280 | """ 281 | return create_request( 282 | url, 283 | method=method, 284 | headers=headers, 285 | data=data, 286 | user=user, 287 | factory=self.factory, 288 | ) 289 | 290 | def make_request(self, view_class, request, *args, **kwargs): 291 | """ 292 | Simulates the request/response cycle against an ApiView. 293 | 294 | Args: 295 | view_class (ApiView): The class to test against. 296 | request (Request): The request to be sent. 297 | *args (list): (Optional) Any positional URLconf arguments. 298 | **kwargs (dict): (Optional) Any keyword URLconf arguments. 299 | 300 | Returns: 301 | HttpResponse: The received response. 302 | """ 303 | view_func = view_class.as_view() 304 | return view_func(request, *args, **kwargs) 305 | 306 | def assertStatusCode(self, resp, status_code): 307 | """ 308 | Checks the response for a specific status code. 309 | 310 | There are many specialized variants included with this class, so this 311 | is really only needed when you need to support a rarer HTTP status code. 312 | 313 | Args: 314 | resp (django.http.HttpResponse): The response from the view. 315 | status_code (int): The desired HTTP status code. 316 | 317 | Raises: 318 | AssertionError: If the expected status code does not match the 319 | response. 320 | """ 321 | assert_status_code(resp, status_code) 322 | 323 | def assertOK(self, resp): 324 | """ 325 | Checks the response for an `HTTP 200 OK`. 326 | 327 | Args: 328 | resp (django.http.HttpResponse): The response from the view. 329 | 330 | Raises: 331 | AssertionError: If the response's status code is not a `200`. 332 | """ 333 | assert_ok(resp) 334 | 335 | def assertCreated(self, resp): 336 | """ 337 | Checks the response for an `HTTP 201 Created`. 338 | 339 | Args: 340 | resp (django.http.HttpResponse): The response from the view. 341 | 342 | Raises: 343 | AssertionError: If the response's status code is not a `201`. 344 | """ 345 | assert_created(resp) 346 | 347 | def assertAccepted(self, resp): 348 | """ 349 | Checks the response for an `HTTP 202 Accepted`. 350 | 351 | Args: 352 | resp (django.http.HttpResponse): The response from the view. 353 | 354 | Raises: 355 | AssertionError: If the response's status code is not a `202`. 356 | """ 357 | assert_accepted(resp) 358 | 359 | def assertNoContent(self, resp): 360 | """ 361 | Checks the response for an `HTTP 204 No Content`. 362 | 363 | Args: 364 | resp (django.http.HttpResponse): The response from the view. 365 | 366 | Raises: 367 | AssertionError: If the response's status code is not a `204`. 368 | """ 369 | assert_no_content(resp) 370 | 371 | def assertBadRequest(self, resp): 372 | """ 373 | Checks the response for an `HTTP 400 Bad Request`. 374 | 375 | Args: 376 | resp (django.http.HttpResponse): The response from the view. 377 | 378 | Raises: 379 | AssertionError: If the response's status code is not a `400`. 380 | """ 381 | assert_bad_request(resp) 382 | 383 | def assertUnauthorized(self, resp): 384 | """ 385 | Checks the response for an `HTTP 401 Unauthorized`. 386 | 387 | Args: 388 | resp (django.http.HttpResponse): The response from the view. 389 | 390 | Raises: 391 | AssertionError: If the response's status code is not a `401`. 392 | """ 393 | assert_unauthorized(resp) 394 | 395 | def assertForbidden(self, resp): 396 | """ 397 | Checks the response for an `HTTP 403 Forbidden`. 398 | 399 | Args: 400 | resp (django.http.HttpResponse): The response from the view. 401 | 402 | Raises: 403 | AssertionError: If the response's status code is not a `403`. 404 | """ 405 | assert_forbidden(resp) 406 | 407 | def assertNotFound(self, resp): 408 | """ 409 | Checks the response for an `HTTP 404 Not Found`. 410 | 411 | Args: 412 | resp (django.http.HttpResponse): The response from the view. 413 | 414 | Raises: 415 | AssertionError: If the response's status code is not a `404`. 416 | """ 417 | assert_not_found(resp) 418 | 419 | def assertNotAllowed(self, resp): 420 | """ 421 | Checks the response for an `HTTP 405 Not Allowed`. 422 | 423 | Args: 424 | resp (django.http.HttpResponse): The response from the view. 425 | 426 | Raises: 427 | AssertionError: If the response's status code is not a `405`. 428 | """ 429 | assert_not_allowed(resp) 430 | 431 | def assertAppError(self, resp): 432 | """ 433 | Checks the response for an `HTTP 500 Application Error`. 434 | 435 | Args: 436 | resp (django.http.HttpResponse): The response from the view. 437 | 438 | Raises: 439 | AssertionError: If the response's status code is not a `500`. 440 | """ 441 | assert_app_error(resp) 442 | 443 | def assertResponseEquals(self, resp, data): 444 | """ 445 | Checks for a valid response & asserts the response body matches the 446 | expected data. 447 | 448 | This checks for: 449 | * A valid `Content-Type` header 450 | * Loads the JSON body 451 | * Asserts the response data equals the expected data 452 | 453 | Args: 454 | resp (django.http.HttpResponse): The response from the view. 455 | data (dict or list): The expected data. 456 | 457 | Raises: 458 | AssertionError: If the response is invalid or doesn't match the data. 459 | ValueError: If a body is present, but is not valid JSON. 460 | """ 461 | resp_data = check_response(resp) 462 | self.assertEqual(resp_data, data) 463 | -------------------------------------------------------------------------------- /src/microapi/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.http import JsonResponse 4 | from django.utils.decorators import classonlymethod 5 | from django.views.decorators.csrf import csrf_exempt 6 | from django.views.generic.base import View 7 | 8 | from .exceptions import ( 9 | ApiError, 10 | # InvalidFieldError, 11 | # DataValidationError, 12 | ) 13 | from . import http 14 | from .serializers import ModelSerializer 15 | 16 | 17 | class ApiView(View): 18 | """ 19 | Just a bit of sugar on top of plain ol' `View`. 20 | 21 | Used as a base class for your API views to inherit from, & provides 22 | conveniences to make writing JSON APIs easier. 23 | 24 | Usage:: 25 | 26 | from microapi import ApiView, ModelSerializer 27 | 28 | from .models import BlogPost 29 | 30 | 31 | # Inherit from the `ApiView` class... 32 | class BlogPostView(ApiView): 33 | # ...then define `get`/`post`/`put`/`delete`/`patch` methods on the 34 | # subclass. 35 | 36 | # For example, we'll provide a list view on `get`. 37 | def get(self, request): 38 | posts = BlogPost.objects.all().order_by("-created") 39 | 40 | # The `render` method automatically creates a JSON response from 41 | # the provided data. 42 | return self.render({ 43 | "success": True, 44 | "posts": self.serialize_many(posts), 45 | }) 46 | 47 | # And handle creating a new blog post on `post`. 48 | def post(self, request): 49 | if not request.user.is_authenticated: 50 | return self.render_error("You must be logged in") 51 | 52 | # Read the JSON 53 | data = self.read_json(request) 54 | 55 | # TODO: Validate the data here. 56 | 57 | serializer = ModelSerializer() 58 | post = serializer.from_dict(BlogPost(), data) 59 | post.save() 60 | 61 | return self.render({ 62 | "success": True, 63 | "post": self.serialize(post), 64 | }) 65 | 66 | """ 67 | 68 | #: Control whether we handle exceptions or allow them to bubble up to Django. 69 | bubble_exceptions = False 70 | #: Control which HTTP methods are allowed to be responded to. 71 | http_method_names = [ 72 | "get", 73 | "post", 74 | "put", 75 | "patch", 76 | "delete", 77 | ] 78 | #: What serializer we use by default. 79 | serializer = ModelSerializer() 80 | 81 | @classonlymethod 82 | def as_view(cls, **initkwargs): 83 | view = super().as_view(**initkwargs) 84 | return csrf_exempt(view) 85 | 86 | def dispatch(self, request, *args, **kwargs): 87 | """ 88 | Light override to the built-in `dispatch`, to allow for automatic 89 | JSON serialization of errors (as opposed to HTML). 90 | 91 | If you need the Django debug error, you can set the `bubble_exceptions` 92 | attribute on the class to `True`. 93 | 94 | Args: 95 | request (HttpRequest): The provided request. 96 | *args (list): The unnamed view arguments from the URLconf. 97 | **kwargs (dict): The named view arguments from the URLconf. 98 | 99 | Returns: 100 | HttpResponse: Typically, a JSON-encoded response. 101 | """ 102 | try: 103 | return super().dispatch(request, *args, **kwargs) 104 | except Exception as err: 105 | if self.bubble_exceptions: 106 | raise 107 | 108 | return self.render_error(str(err)) 109 | 110 | def read_json(self, request, strict=True): 111 | """ 112 | Reads the request body & returns the decoded JSON. 113 | 114 | Args: 115 | request (HttpRequest): The received request 116 | strict (bool): (Optional) If provided, requires "correct" JSON 117 | headers to be present. Default is `True`. 118 | 119 | Returns: 120 | dict: The decoded JSON body 121 | """ 122 | valid_content_types = [ 123 | "application/json", 124 | ] 125 | 126 | if strict: 127 | # Check the Content-Type. 128 | if request.headers.get("Content-type", "") not in valid_content_types: 129 | raise ApiError("Invalid Content-type provided.") 130 | 131 | try: 132 | return json.loads(request.body) 133 | except ValueError: 134 | raise ApiError("Invalid JSON payload provided.") 135 | 136 | def render(self, data, status_code=http.OK): 137 | """ 138 | Creates a JSON response. 139 | 140 | Args: 141 | data (dict): The data to return as JSON 142 | status_code (int): The desired HTTP status code. Default is 143 | `http.OK` (a.k.a. `200`). 144 | 145 | Returns: 146 | JsonResponse: The response for Django to provide to the user 147 | """ 148 | return JsonResponse(data, status=status_code) 149 | 150 | def render_error(self, msgs, status_code=http.APP_ERROR): 151 | """ 152 | Creates an error JSON response. 153 | 154 | Args: 155 | msgs (list|str): A list of message(s) to provide to the user. If a 156 | single string is provided, this will automatically get turned 157 | into a list. 158 | status_code (int): The desired HTTP status code. Default is 159 | `http.APP_ERROR` (a.k.a. `500`). 160 | 161 | Returns: 162 | JsonResponse: The error response for Django to provide to the user 163 | """ 164 | if not isinstance(msgs, (list, tuple)): 165 | # In case of a single string. 166 | msgs = [msgs] 167 | 168 | return self.render( 169 | { 170 | "success": False, 171 | "errors": msgs, 172 | }, 173 | status_code=status_code, 174 | ) 175 | 176 | def validate(self, data): 177 | """ 178 | A method for standardizing validation. Not automatically called 179 | anywhere. 180 | 181 | Expected behavior is to return the validated data on success, or to 182 | call `render_error` with failures. 183 | 184 | This **MUST** be implemented in the subclass by the user. 185 | 186 | Args: 187 | data (dict): The data provided by the user. 188 | 189 | Returns: 190 | dict: The validated data 191 | """ 192 | raise NotImplementedError("Subclass must implement the 'validate' method.") 193 | 194 | def serialize(self, obj): 195 | """ 196 | A method for standardizing serialization. 197 | 198 | A "rich" object (like a `Model`) is provided, & turned into a dict that 199 | is ready for JSON serialization. 200 | 201 | By default, this provides some basic serialization for Django `Model` 202 | instances via `ModelSerializer`. You can extend this method to embelish 203 | the data, or override to perform your own custom serialization. 204 | 205 | Args: 206 | obj (Model): The object to serialize. 207 | 208 | Returns: 209 | dict: The data 210 | """ 211 | return self.serializer.to_dict(obj) 212 | 213 | def serialize_many(self, objs): 214 | """ 215 | Like `serialize`, but handles serialization for many objects. 216 | 217 | Args: 218 | objs (iterable): An iterable of the objects to serialize. 219 | 220 | Returns: 221 | list: A list of serialized objects 222 | """ 223 | data = [] 224 | 225 | for obj in objs: 226 | data.append(self.serialize(obj)) 227 | 228 | return data 229 | -------------------------------------------------------------------------------- /test/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toastdriven/django-microapi/dab8c071466bdcb326e1990984f6786e4b433382/test/config/__init__.py -------------------------------------------------------------------------------- /test/config/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for config project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /test/config/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for config project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.2.7. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "pretty-insecure" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sessions", 38 | "django.contrib.messages", 39 | "django.contrib.staticfiles", 40 | "test_microapi", 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | "django.middleware.security.SecurityMiddleware", 45 | "django.contrib.sessions.middleware.SessionMiddleware", 46 | "django.middleware.common.CommonMiddleware", 47 | "django.middleware.csrf.CsrfViewMiddleware", 48 | "django.contrib.auth.middleware.AuthenticationMiddleware", 49 | "django.contrib.messages.middleware.MessageMiddleware", 50 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 51 | ] 52 | 53 | ROOT_URLCONF = "config.urls" 54 | 55 | TEMPLATES = [ 56 | { 57 | "BACKEND": "django.template.backends.django.DjangoTemplates", 58 | "DIRS": [], 59 | "APP_DIRS": True, 60 | "OPTIONS": { 61 | "context_processors": [ 62 | "django.template.context_processors.debug", 63 | "django.template.context_processors.request", 64 | "django.contrib.auth.context_processors.auth", 65 | "django.contrib.messages.context_processors.messages", 66 | ], 67 | }, 68 | }, 69 | ] 70 | 71 | WSGI_APPLICATION = "config.wsgi.application" 72 | 73 | 74 | # Database 75 | # https://docs.djangoproject.com/en/4.2/ref/settings/#databases 76 | 77 | DATABASES = { 78 | "default": { 79 | "ENGINE": "django.db.backends.sqlite3", 80 | "NAME": BASE_DIR / "db.sqlite3", 81 | } 82 | } 83 | 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators 87 | 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 91 | }, 92 | { 93 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 94 | }, 95 | { 96 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 97 | }, 98 | { 99 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 100 | }, 101 | ] 102 | 103 | 104 | # Internationalization 105 | # https://docs.djangoproject.com/en/4.2/topics/i18n/ 106 | 107 | LANGUAGE_CODE = "en-us" 108 | 109 | TIME_ZONE = "UTC" 110 | 111 | USE_I18N = True 112 | 113 | USE_TZ = True 114 | 115 | 116 | # Static files (CSS, JavaScript, Images) 117 | # https://docs.djangoproject.com/en/4.2/howto/static-files/ 118 | 119 | STATIC_URL = "static/" 120 | 121 | # Default primary key field type 122 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field 123 | 124 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 125 | -------------------------------------------------------------------------------- /test/config/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for config project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/4.2/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | # from django.contrib import admin 18 | from django.urls import include, path 19 | 20 | urlpatterns = [ 21 | # path("admin/", admin.site.urls), 22 | path("", include("test_microapi.urls")), 23 | ] 24 | -------------------------------------------------------------------------------- /test/config/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for config project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /test/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /test/test_microapi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toastdriven/django-microapi/dab8c071466bdcb326e1990984f6786e4b433382/test/test_microapi/__init__.py -------------------------------------------------------------------------------- /test/test_microapi/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestMicroAPIConfig(AppConfig): 5 | name = "test_microapi" 6 | verbose_name = "Test MicroAPI" 7 | -------------------------------------------------------------------------------- /test/test_microapi/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0 on 2023-12-05 15:51 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='BlogPost', 19 | fields=[ 20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('title', models.CharField(max_length=64)), 22 | ('slug', models.SlugField(blank=True)), 23 | ('content', models.TextField(blank=True, default='')), 24 | ('published_on', models.DateTimeField(auto_now_add=True, db_index=True)), 25 | ('published_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blog_posts', to=settings.AUTH_USER_MODEL)), 26 | ], 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /test/test_microapi/migrations/0002_alter_blogpost_published_on_alter_blogpost_slug.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0 on 2023-12-05 17:10 2 | 3 | import django.utils.timezone 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('test_microapi', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='blogpost', 16 | name='published_on', 17 | field=models.DateTimeField(db_index=True, default=django.utils.timezone.now), 18 | ), 19 | migrations.AlterField( 20 | model_name='blogpost', 21 | name='slug', 22 | field=models.SlugField(blank=True, unique=True), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /test/test_microapi/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toastdriven/django-microapi/dab8c071466bdcb326e1990984f6786e4b433382/test/test_microapi/migrations/__init__.py -------------------------------------------------------------------------------- /test/test_microapi/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.db import models 3 | from django.utils.text import slugify 4 | from django.utils import timezone 5 | 6 | 7 | User = get_user_model() 8 | 9 | 10 | class BlogPost(models.Model): 11 | title = models.CharField(max_length=64) 12 | slug = models.SlugField(blank=True, unique=True, db_index=True) 13 | content = models.TextField(blank=True, default="") 14 | published_by = models.ForeignKey( 15 | User, 16 | related_name="blog_posts", 17 | on_delete=models.CASCADE, 18 | ) 19 | published_on = models.DateTimeField(default=timezone.now, db_index=True) 20 | 21 | def __str__(self): 22 | return f"{self.title}" 23 | 24 | def save(self, *args, **kwargs): 25 | if not self.slug: 26 | self.slug = slugify(self.title) 27 | 28 | return super().save(*args, **kwargs) 29 | -------------------------------------------------------------------------------- /test/test_microapi/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toastdriven/django-microapi/dab8c071466bdcb326e1990984f6786e4b433382/test/test_microapi/tests/__init__.py -------------------------------------------------------------------------------- /test/test_microapi/tests/test_serializers.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.test import TestCase 4 | from django.utils.timezone import make_aware 5 | 6 | from microapi import ModelSerializer 7 | from microapi.exceptions import InvalidFieldError 8 | 9 | from ..models import ( 10 | BlogPost, 11 | User, 12 | ) 13 | 14 | 15 | class ModelSerializerTestCase(TestCase): 16 | def setUp(self): 17 | super().setUp() 18 | self.serializer = ModelSerializer() 19 | self.user = User.objects.create_user( 20 | "testmctest", 21 | "test@mctest.com", 22 | "testpass", 23 | ) 24 | 25 | def test_collect_field_names_post(self): 26 | names = self.serializer.collect_field_names(BlogPost) 27 | self.assertEqual(names, ["content", "id", "published_on", "slug", "title"]) 28 | 29 | def test_collect_field_names_user(self): 30 | serializer = ModelSerializer() 31 | names = serializer.collect_field_names(User) 32 | self.assertEqual( 33 | names, 34 | [ 35 | "date_joined", 36 | "email", 37 | "first_name", 38 | "id", 39 | "is_active", 40 | "is_staff", 41 | "is_superuser", 42 | "last_login", 43 | "last_name", 44 | "password", 45 | "username", 46 | ], 47 | ) 48 | 49 | def test_to_dict(self): 50 | # Sanity check. 51 | self.assertEqual(BlogPost.objects.count(), 0) 52 | 53 | post = BlogPost.objects.create( 54 | title="Hello, World!", 55 | content="My first post! *SURELY*, it won't be the last...", 56 | published_by=self.user, 57 | published_on=make_aware( 58 | datetime.datetime(2023, 11, 28, 9, 26, 54, 123456), 59 | timezone=datetime.timezone.utc, 60 | ), 61 | ) 62 | 63 | data = self.serializer.to_dict(post) 64 | 65 | self.assertEqual( 66 | [key for key in data], 67 | ["content", "id", "published_on", "slug", "title"], 68 | ) 69 | 70 | # To prevent test failures due to whatever PK is assigned. 71 | data.pop("id") 72 | 73 | self.assertEqual( 74 | data["content"], "My first post! *SURELY*, it won't be the last..." 75 | ) 76 | self.assertEqual(data["published_on"].year, 2023) 77 | self.assertEqual(data["published_on"].month, 11) 78 | self.assertEqual(data["published_on"].day, 28) 79 | self.assertEqual(data["slug"], "hello-world") 80 | self.assertEqual(data["title"], "Hello, World!") 81 | 82 | def test_from_dict(self): 83 | post = BlogPost() 84 | 85 | self.serializer.from_dict( 86 | post, 87 | { 88 | "title": "Life Update", 89 | "content": "So, it's been awhile...", 90 | }, 91 | ) 92 | 93 | post.published_by = self.user 94 | post.save() 95 | 96 | self.assertEqual(post.content, "So, it's been awhile...") 97 | self.assertEqual(post.slug, "life-update") 98 | self.assertEqual(post.title, "Life Update") 99 | 100 | def test_from_dict_strict_fail(self): 101 | post = BlogPost() 102 | 103 | with self.assertRaises(InvalidFieldError): 104 | self.serializer.from_dict( 105 | post, 106 | { 107 | "lmao": "so_invalid", 108 | "title": "Life Update", 109 | "content": "So, it's been awhile...", 110 | }, 111 | strict=True, 112 | ) 113 | 114 | def test_from_dict_strict_success(self): 115 | post = BlogPost() 116 | 117 | # This shouldn't raise any exceptions. 118 | # If so, we're good & test passes. 119 | self.serializer.from_dict( 120 | post, 121 | { 122 | "title": "Life Update", 123 | "content": "So, it's been awhile...", 124 | }, 125 | strict=True, 126 | ) 127 | -------------------------------------------------------------------------------- /test/test_microapi/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.utils.timezone import make_aware 4 | 5 | from microapi.tests import ( 6 | ApiTestCase, 7 | check_response, 8 | ) 9 | 10 | from ..models import ( 11 | BlogPost, 12 | User, 13 | ) 14 | from ..views import ( 15 | BlogPostListView, 16 | BlogPostDetailView, 17 | OhHellNoError, 18 | ) 19 | 20 | 21 | class BlogPostApiTestCase(ApiTestCase): 22 | def setUp(self): 23 | super().setUp() 24 | self.user = User.objects.create_user( 25 | "testmctest", 26 | "teest@mctest.com", 27 | "testpass", 28 | ) 29 | self.post_1 = BlogPost.objects.create( 30 | title="Hello, World!", 31 | content="My first post! *SURELY*, it won't be the last...", 32 | published_by=self.user, 33 | published_on=make_aware( 34 | datetime.datetime(2023, 11, 28, 9, 26, 54, 123456), 35 | timezone=datetime.timezone.utc, 36 | ), 37 | ) 38 | self.post_2 = BlogPost.objects.create( 39 | title="Life Update", 40 | content="So, it's been awhile...", 41 | published_by=self.user, 42 | published_on=make_aware( 43 | datetime.datetime(2023, 12, 5, 10, 3, 22, 123456), 44 | timezone=datetime.timezone.utc, 45 | ), 46 | ) 47 | 48 | def test_posts_get_list(self): 49 | req = self.create_request( 50 | "/api/v1/posts/", 51 | ) 52 | resp = self.make_request(BlogPostListView, req) 53 | self.assertOK(resp) 54 | 55 | data = check_response(resp) 56 | self.assertTrue(data["success"]) 57 | self.assertTrue(len(data["posts"]), 2) 58 | self.assertEqual(data["posts"][0]["slug"], "life-update") 59 | self.assertEqual(data["posts"][1]["slug"], "hello-world") 60 | 61 | def test_posts_post_list(self): 62 | # Sanity check. 63 | self.assertEqual(BlogPost.objects.count(), 2) 64 | 65 | req = self.create_request( 66 | "/api/v1/posts/", 67 | method="post", 68 | data={ 69 | "title": "Cat Pictures", 70 | "content": "All the internet is good for.", 71 | "published_on": "2023-12-05T11:45:45.000000-0600", 72 | }, 73 | user=self.user, 74 | ) 75 | resp = self.make_request(BlogPostListView, req) 76 | self.assertCreated(resp) 77 | 78 | data = check_response(resp) 79 | self.assertTrue(data["success"]) 80 | self.assertTrue(data["post"]["title"], "Cat Pictures") 81 | self.assertTrue(data["post"]["slug"], "cat-pictures") 82 | self.assertTrue(data["post"]["content"], "All the internet is good for.") 83 | 84 | self.assertEqual(BlogPost.objects.count(), 3) 85 | post = BlogPost.objects.get(slug="cat-pictures") 86 | self.assertEqual(post.published_by.pk, self.user.pk) 87 | self.assertEqual(post.published_on.year, 2023) 88 | self.assertEqual(post.published_on.month, 12) 89 | self.assertEqual(post.published_on.day, 5) 90 | self.assertEqual(post.published_on.hour, 17) 91 | 92 | def test_bubble_exceptions(self): 93 | req = self.create_request( 94 | f"/api/v1/posts/", 95 | method="delete", 96 | user=self.user, 97 | ) 98 | 99 | with self.assertRaises(OhHellNoError): 100 | self.make_request(BlogPostListView, req) 101 | 102 | def test_get_detail(self): 103 | req = self.create_request( 104 | f"/api/v1/posts/{self.post_1.pk}/", 105 | ) 106 | resp = self.make_request(BlogPostDetailView, req, post_id=self.post_1.pk) 107 | self.assertOK(resp) 108 | 109 | data = check_response(resp) 110 | self.assertTrue(data["success"]) 111 | self.assertEqual(data["post"]["slug"], "hello-world") 112 | 113 | def test_put_detail(self): 114 | req = self.create_request( 115 | f"/api/v1/posts/{self.post_1.pk}/", 116 | method="put", 117 | data={ 118 | "content": "Fixed a typo.", 119 | }, 120 | user=self.user, 121 | ) 122 | resp = self.make_request(BlogPostDetailView, req, post_id=self.post_1.pk) 123 | self.assertAccepted(resp) 124 | 125 | data = check_response(resp) 126 | self.assertTrue(data["success"]) 127 | self.assertEqual(data["post"]["slug"], "hello-world") 128 | # The only field that should've been touched. 129 | self.assertEqual(data["post"]["content"], "Fixed a typo.") 130 | 131 | def test_delete_detail(self): 132 | # Sanity check. 133 | self.assertEqual(BlogPost.objects.count(), 2) 134 | 135 | req = self.create_request( 136 | f"/api/v1/posts/{self.post_1.pk}/", 137 | method="delete", 138 | user=self.user, 139 | ) 140 | resp = self.make_request(BlogPostDetailView, req, post_id=self.post_1.pk) 141 | self.assertNoContent(resp) 142 | 143 | self.assertEqual(BlogPost.objects.count(), 1) 144 | 145 | with self.assertRaises(BlogPost.DoesNotExist): 146 | BlogPost.objects.get(slug="hello-world") 147 | 148 | def test_no_patch_shortcuts(self): 149 | with self.assertRaises(ValueError) as err: 150 | self.create_request( 151 | f"/api/v1/posts/{self.post_1.pk}/", 152 | method="patch", 153 | data={ 154 | "nope": "nopenope", 155 | }, 156 | user=self.user, 157 | ) 158 | 159 | self.assertTrue("does not support PATCH" in str(err)) 160 | -------------------------------------------------------------------------------- /test/test_microapi/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import ( 4 | BlogPostListView, 5 | BlogPostDetailView, 6 | ) 7 | 8 | urlpatterns = [ 9 | path("api/v1/posts/", BlogPostListView.as_view()), 10 | path("api/v1/posts//", BlogPostDetailView.as_view()), 11 | ] 12 | -------------------------------------------------------------------------------- /test/test_microapi/views.py: -------------------------------------------------------------------------------- 1 | from microapi import ApiView 2 | from microapi import http 3 | 4 | from .models import BlogPost 5 | 6 | 7 | class OhHellNoError(Exception): 8 | pass 9 | 10 | 11 | class BlogPostListView(ApiView): 12 | bubble_exceptions = True 13 | 14 | def get(self, request): 15 | posts = BlogPost.objects.all().order_by("-published_on") 16 | return self.render( 17 | { 18 | "success": True, 19 | "posts": self.serialize_many(posts), 20 | } 21 | ) 22 | 23 | def post(self, request): 24 | data = self.read_json(request) 25 | new_post = BlogPost() 26 | 27 | self.serializer.from_dict(new_post, data) 28 | 29 | new_post.published_by = request.user 30 | new_post.save() 31 | 32 | return self.render( 33 | { 34 | "success": True, 35 | "post": self.serialize(new_post), 36 | }, 37 | status_code=http.CREATED, 38 | ) 39 | 40 | def delete(self, request): 41 | # This is a supported method, but we want to test exception bubbling. 42 | # So raise an unhandled exception! 43 | raise OhHellNoError("I don't think so.") 44 | 45 | 46 | class BlogPostDetailView(ApiView): 47 | # Not an HTTP method. 48 | def get_blog_post(self, post_id): 49 | return BlogPost.objects.get(pk=post_id) 50 | 51 | def get(self, request, post_id): 52 | try: 53 | post = self.get_blog_post(post_id) 54 | except BlogPost.DoesNotExist: 55 | return self.render_error("Post does not exist") 56 | 57 | return self.render( 58 | { 59 | "success": True, 60 | "post": self.serialize(post), 61 | } 62 | ) 63 | 64 | def put(self, request, post_id): 65 | data = self.read_json(request) 66 | post = self.get_blog_post(post_id) 67 | 68 | self.serializer.from_dict(post, data, strict=True) 69 | post.save() 70 | 71 | return self.render( 72 | { 73 | "success": True, 74 | "post": self.serialize(post), 75 | }, 76 | status_code=http.ACCEPTED, 77 | ) 78 | 79 | def delete(self, request, post_id): 80 | post = self.get_blog_post(post_id) 81 | post.delete() 82 | return self.render({}, status_code=http.NO_CONTENT) 83 | --------------------------------------------------------------------------------