api

This library allows a django website to add an api quickly and easily.

Overview

api is a library to add an API to any Django website. It has been inspired by the AWS API - control and security are a big part. Once set up, you can add methods to your site with minimal code - in yourapp/api.py:

@api.method
def manipulate_something(rl:Role, message:str, thing:MyModel)->Result:
    # manipulate thing
    return Result(...)

This makes yourapp/manipulate_something available in your site's API. Your site's API is called using POSTs, so any language should be able to call it easily (POST to https://yoursite/api/yourapp/manipulate_something). You can post from your site's web pages, securing them using {% csrf_token %} as normal. For Python clients there is a helper library, api_api, which wraps your API as ordinary Python method calls. Any methods you define can be called from your site's code well as through your site's API. When called from your code, security is checked using the Role you pass in, just the same as for an API call.

[aside: making an API trivial to write and use has meant we now take an API-first approach to developing my web sites, removing all POST processing from the UI views (we now never use Django forms). In particular, we have found: creating a dynamic/instant response UI is simple; no more 'are you sure you want to post that again' for our sites' users; testing is much more straightforward (we are now a convert to writiing automated tests); our sites' APIs are not an add-on, so don't have gaps]

Your site's API will be able to use values which are:

  • str, int, float, bool, bytes, None
  • Enums of simple values
  • model.Models. Sometimes called a resource, they will need a little preparation for use in your API
  • json
  • @dataclasss
  • file uploads

A model.Model, or resource is given in an API either by an id (eg 'u-0123456789abcdef'), or a urn (eg 'urn/home/user/fredbloggs'). A resource's id is fixed and is made from its primary key, and its urn is a path to find the resource - a urn must only match one resource. All ids and urns are unique on your site - no two resources, no matter what type, will have the same one. Your model needs a little preparation, see here for details. This, amongst other things, sets the id prefix ('i' in 'i-012..'), and urn tag ('home/user' in 'urn/home/user/fre..'). When ready, your resource can be used in your api:

@api.Method
def find_repo(rl:Role, user:User, name:str)->Repo:
    return Repo.objects.find(user=user, name=name)

Sometimes, you may need to convert between instances and ids - once prepared, your model will have .urn, .api_id and .api_find(). Here we're calling the api of another instance in our website:

# tell my other instance to create the repo...
instance.api.repo.create_repo(repo=repo.api_id)

api_api is .api_id-aware, so this also works

instance.api.repo.create_repo(repo=repo)

File uploads are very easy - use api_api.FileUpload as a parameter:

from api_api import FileUpload

@api-method
def receive_file(rl:Role, file:FileUpload):
    ...

The file parameter will be a FileUpload, which has .name, .file and .content_type properties. Sending a FileUpload is as simple as adding a <input type="file".. to the form on your web page, or passing in a FileUpload to your method in api_api.

Security

Security is a big part of any API. The key parts are:

  • Policys - these describe what's allowed (api calls) to what (objects / rows in tables). Policys can also be attached to objects to provide additional control.
  • Roles - these connect who to a series of Policys. A Role can inherit permission Roles this could be used allow, say, temprary, restricted access to otherwise inaccessible objects - in effect, at setuid-like ability.
  • Keys - these deintify the Role making the call.

Requirements on Your Code

api needs help from your code:

  • any model which you want to used in an api needs to be api-ready. This means adding api.Mixin and api configuration. More details here
  • Django's User model needs to be api-ready and to provide a default_user. You will need to replace the Django User model with your own. Django expects this - there is a recipe to follow. However, it is much easier before any Users have been added. Details here.
  • add api.py in each of your project's applications for that application's api methods. More details here

You May Also Need To...

You may need pages on your site for users to manage Policys, Roles and Keys.

Setup

Install the library:

pip install api_svnplace

Adjust Your settings.py

Add api to your site's installed apps. The exact position isn't critical, somewhere after the django apps.

INSTALLED_APPS = [
    ...
    'api',
]

Add api's middlewhere late on in the middleware list, after authentication and security:

MIDDLEWARE = [
    ...
    'api.middleware.APIMiddleware',
]

Add api to your URLs:

urlpatterns = [
    ...
    path('api/', include('api.urls', namespace='api')),
    ...
]

Add API_ settings:

# API

# Needs to be between 4 and 56 bytes long
# Set your own and keep it secret
API_ID_ENCRYPTION_KEY = b'secret secret secret secret'

# Needs to be 8 bytes long
# Set your own and keep it secret
API_ID_INITIALISATION_VECTOR = b'>secret<'

Replace the Django User Model

Which application provides the replacement User model is not important - in the examples the application home has been used.

In settings.py:

AUTH_USER_MODEL="home.User"

and in home/models.py:

from django.contrib.auth.models import AbstractUser
import api

class User(AbstractUser, api.Mixin):
    # api support...
    class API:
        urn_prefix = 'home/user'
        api_id_prefix = 'u-'
        textname = 'user'

    @property
    def owning_user(self):
        return self

    @property
    def urn_id(self):
        return f'{self.username}'

    @staticmethod
    def urn_find(username):
        return User.objects.get(username=username)

    @property
    def default_role(self):
        role, created = self.roles.get_or_create(name='')

        if created:
            role.role_policies.add(home.allow_everything)

        return role
    # ...api support

For more on getting a model ready for use in the api see here.

Create A Site User

Sometimes your site needs to own things. The easiest way to achieve this is to have a User set aside just for this purpose - the Site User.

in home/__init__.py:

default_app_config = 'home.apps.HomeConfig'
site_user = None

in home/apps.py:

from django.apps import AppConfig
from django.contrib import auth
from django.db import OperationalError
import home

class HomeConfig(AppConfig):
    def ready(self) -> None:
        super().ready()

        try:
            UserModel = auth.get_user_model()
            home.site_user = UserModel.objects.get(username='svnplace')
        except (OperationalError, UserModel.DoesNotExist):
            # this allows MakeMigrations etc before the tables have been created
            pass

Ensure Allow Everything Exists

This Policy allows anything on anything. Its text defines it:

statements:
    -
        actions: "*"
        allow: true
        resources: "*"

Somewhere in your code:

from api.models import Policy

home.allow_everything, _ = Policy.objects.update_or_create(
    user=home.site_user,
    name='Allow Everything',
    defaults={
        'text':'''statements:
    -
        actions: "*"
        allow: true
        resources: "*"
''',
        'public':True
    },
)

This could be in HomeConfig.ready(), but that makes reloading a whole database with Django difficult. My approach was to separated out the site preparation code into a ready_site() method called from a ./manage.py readysite, a new command I added to my own site. Details here.

in home/__init__.py:

default_app_config = 'home.apps.HomeConfig'
site_user = None
allow_everything = None

In home/apps.py:

class HomeConfig(AppConfig):
    def ready(self) -> None:
        ...

        from api.models import Policy
        try:
            home.allow_everything = Policy.objects.get(name='Allow Everything')
        except (OperationalError, Policy.DoesNotExist):
            # this allows MakeMigrations etc before the tables have been created
            pass

Add Methods To Your Site

Create an api.py in any applications which need an api:

home
    ...
    admin.py
    api.py
    apps.py
    ...

Inside api.py add your methods:

from dataclasses import dataclass
import api
from api.models import Role

@dataclass
class SiteDescription:
    name: str

@api.method:
def describe_site(rl:Role)->SiteDescription:
    return SiteDescription(name='My wonderful site')

Get A Key And Secret

This is how to create and print a key and its secret:

from api.models import Key

key = Key(role=user.default_role)
secret = key.create_secret()
key.save()
print(key.api_id)
print(secret.decode())

This code will get you going, although you will want a way for your users to manage their keys.

Use Your Site's Methods

Prepare by storing your credentials:

in ~/.mywonderfulsite/config:

[default]
access_key_id = k-0123456789abcdef
access_key_secret = abcdefghijklmnopqrstABCDEFGHIJKLMNOPQRST

access_key_id is key.api_id from here and access_key_secret is secret.decode().

Call your api methods:

from api_api import API

api = API('http://mywonderfulsite.com/api', ['~/.mywonderfulsite/config'])

site_description = api.home.describe_site()

This example shows calling your API using Python. The call is made using an HTTP POST, and the result is returned as json. This makes it simple to embed api calls as <form>s inside your web pages, and to call your api from your clients' languages of choice.

More details can be found here

Further Reading

You may need to use your models in your API. Each model will need a little preparation. The details can be found here.

Details to write methods can be found here

A guide to create a ready_site command for your project can be found here.