Preparing A Model For Your API

Overview

in api an instance of a model.Model is called a resource. Resources need to be identified in your api. This is done in two ways:

  • as a URN. This is a more human readable identifier, eg urn/home/user/joebloggs
  • using an api id. This is a brief identifier, eg u-0123456789abcdef

api needs work out whether to allow a call which uses a resource. To support this each resource must be owned by a User. The default behvaiour says the owner and superusers can use a resource, but nobody else. This behaviour can be overridden in the resource's code - override allows_api_check_user(), which is a good place, for example, to allow public read access. Alternatively, you could create a Role to access the resource and setting this Role as a parent to a Role of the other user - this is a good way, for example, to allow elevated access for a particular task.

An api id is derived from a resource's pk. The pk is encrypted to make the id. The settings API_ID_ENCRYPTION_KEY and API_ID_INITIALISATION_VECTOR set how the pk is encrypted. By default, pk is assumed to be a BigIntegerField - the default for Django. To support other forms of pk, override pk_as_bytes() and pk_from_bytes().

api.Mixin

You add this mixin to your model.Models, It needs configuration from your model - see setup. It provides these:

.urn
The urn of the resource.
.api_id
The api id of the resource.
api_find(id:str)->cls classmethod
Find one of cls given an id, which can be a urn id or a api id. Raises cls.DoesNotExist if that resource could not be found.

Setup

Add api's mixin to your model and define these:

class Author(models.Model, api.Mixin):

    # api support...
    class API:
        urn_prefix = 'writing/author'
        api_id_prefix = 'a-'
        textname = 'author'

    @property
    def owning_user(self):
        return self.owner

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

    @staticmethod
    def urn_find(fullname):
        return Author.objects.get(fullname=fullname)

    # ...api support

Required in YourModel

.API

This class provides configuration for the api for your Model

.API.urn_prefix
Used for urns for your model. In urn/home/user/joebloggs it's home/user. These must be unique.
.API.api_id_prefix
Used for api ids for your model. In u-0123456789abcdef it's the u. These must be unique.
.API.textname
Used to refer to your model, escpecially when building the text for an error. It is helpful if these are unique.
.owning_user
Who owns this. By default, a super user always allowed to do anything to any object and the object's owner is allowed anything if the Role allows it and everybody else is not allowed to do anything. This is the default behaviour which can be overidden.
.urn_id
The part of the urn for this particular model. In urn urn/home/user/joebloggs it is joebloggs, and in urn/home/user/joe/bloggs it is joe/bloggs:
def urn_id(self):
    return self.fullname

or

def uri_id(self):
    return self.firstname + '/' + self.lastname
.urn_find()
The method to find one of your objects from the urn. The parameters are the path parts.
@staticmethod
def urn_find(fullname):
    return Author.objects.get(fullname=fullname)

or

@staticmethod
def urn_find(firstname, lastname):
    return Author.objects.get(firstname=firstname, lastname=lastname)

Optional In YourModel

.policies

optional When present, this is expected to be a ManyToManyField like this:

policies = models.ManyToManyField(
    Policy,
    blank=True,
    related_name='mymodels',
    related_query_name='mymodel',
    through='MyModelUsesPolicy',
    through_fields=('resource', 'policy', )
)

where MyModelUsesPolicy is like this:

from api.models import ResourceUsesPolicy
class MyModelUsesPolicy(ResourceUsesPolicy(MyModel)):
    pass

This allows policies to be attached to your resource (in the example called MyModel). A Policy used in this way has the usual context, plus:

resource.api_id
The api_id of the resource
resource.urn
The urn of the resource
resource.owning_user
The owning user of the resource (not present for users)
resource. ...
Any other values made available to contexts by the resource's for_context()

The through model allows additional context to be provided to the policy when it is deciding whether to allow a particular api call. ResourceUsesPolicy(cls) is a class generator. It generates an abstract models.Model with the required fields.

That is the bare essentials for setting up a policies field - you may want to allow your users to adjust their resources' policies lists.

The following is optional, but shows what can also be achieved:

Your users may also want to see how many times they've used different Policys. The real models's classes are kept in a list, ResourceUsesPolicyBase.derived_classes. Here's some suggested extra attributes to help with counting how many times a Policy has been used for what:

class MyModelUsesPolicy(ResourceUsesPolicy(MyModel)):
    # which group Policy will be tallied against
    tally = 'resources'

    @classmethod
    def query_for_owned_by(cls, owner):
        return Q(resource__owner_user=owner)
tally
A tag for identifying what a Policys been used for.
query_for_owned_by
Return a Q-expression for use in an .objects.filter(). The ForeignKey field for your resource is called resource and the resurned Q-expression should filter your resources down to those owned by owner.

These could be used like this:

tallies = defaultdict(lambda: 0)
for cls in ResourceUsesPolicyBase.derived_classes:
    tallies[cls.tally] += cls.objects.filter(policy=policy).filter(cls.query_for_owned_by(user)).count()

Then, for example, tallies['resources'] is the number of time policy has been used by one of your resources.

.allows_api_check_user

Checks the user to see whether they can access your object. Thie default implementation allows access to super users and the owner. In this override, a public flag is checked:

def allows_api_check_user(self, ctx: PolicyContext):
    # allow public repos read access to anybody
    if ctx.access == ResourceAccess.Read and self.public:
        return True

    return super().allows_api_check_user(ctx)

PolicyContext has:

role
the Role being checked
action
the name of the api call, eg 'home/describe_site'
resources
a list of all resources (models items) used in the call
access
the kind of access to the reasource - a member of the ResourceAccess enum.
context
a dictionary of values describing the context of the call
kwargs:
the kwargs for the method call

From an api call:

["source"] - 'session' (web page call) or 'auth' (from authorization)
["originator"]["ip"] - `.META['REMOTE_ADDR']` from the request 
["useragent"] - `.META['HTTP_USER_AGENT']` from the request

From all calls (api and site internal):

["api"]["principal"]["id"] - api_id of the calling user
["api"]["principal"]["username"] - username of the calling user
["api"]["principal"]["urn"] - urn of the calling user
["currenttime"] - datetime of the calling time

Other values may be set by the Role and method. When substituting in Policys, use 'dot form':

{source}
{originator.ip}
{useragent}
{api.principal.id}
{api.principal.username}
{api.principal.urn}
{currenttime}
.pk_as_bytes and .pk_from_bytes
If your pk is not a BigIntegerField, you must define both of these methods. These methods convert your primary key from being a bytes string whose length must be a multiple of 8, and your primary key.

This is the default implementation:

def pk_as_bytes(self):
    return self.pk.to_bytes(8, 'big')

@staticmethod
def pk_from_bytes(b):
    return int.from_bytes(b, "big")

or, in this example, the pk is a string which never has a '\x00' in it:

def pk_as_bytes(self):
    r = self.pk.encode()
    l = len(r)
    r += b'\x00' * ((l+7)//8*8-l)
    return r

@staticmethod
def pk_from_bytes(b):
    return b.replace(b'\x00', b'').decode()
.for_context(self)

When your model is available to policies, such when it is passes as a method argument, this method gives the values are available to the policy. The default implementation is:

class YourModel..
    ...
    def for_context(self):
        return {
            'api_id': self.api_id,
            'urn': self.urn,
            'owning_user': self.owning_user.for_context(),
        }

so, by default, if the argument is thing:

.mything.api_id
Your model's api_id
.mything.urn
Your model's urn
.mything.owning_user
Your model's owning user

Only expose values you are certain are safe, in particular do not expose a model itself - always use its .for_context() instead, as seen above.

The returned dictionary is passed through attrdict - if you need a dictionary to stay as a dictionary it will need to be tagged - refer to the attrdict documentation.