You can easily build a server-side application by using an APIMAS
backend. Currently, the only backend supported is apimas-drf
which is uses django rest framework to build REST APIs on top of
a django application.
In a virtualenv, run the following command to install apimas-drf:
pip install apimas-drf
At this point, we assume that you are familiar with django basic concepts and have some experience with developing django applications.
As a starting point, you have to define your django models. Based on your models and your specification, APIMAS will create the classes implementing the application’s REST API.
According to the guide in section, you can specify a collection of resources named foo, where all REST operations are allowed:
API_SPEC = {
'api': {
'.endpoint': {},
'foo': {
'.collection': {},
'*': {
'text': {
'.string': {}
},
'number': {
'.integer': {},
},
},
'actions': {
'.list': {},
'.retrieve': {},
'.create': {},
'.update': {},
'.delete': {},
}
}
}
}
Given the specification above, you have to create the corresponding
django-model in the project’s models.py
file.
from django.db import models
class Foo(models.Model):
text = models.CharField(max_length=20)
number = models.IntegerField()
In order to link the specification of the collection to the django model
you have to declare ‘foo’ as a django rest framework collection
and text and number as fields, using the predicates
.drf_collection
and .drf_field
, respectively:
API_SPEC = {
'api': {
'.endpoint': {},
'foo': {
'.collection': {},
'.drf_collection': {
'model': 'myapp.models.Foo'
},
'*': {
'text': {
'.string': {},
'.drf_field': {},
},
'number': {
'.integer': {},
'.drf_field': {},
},
},
'actions': {
'.list': {},
'.retrieve': {},
'.create': {},
'.update': {},
'.delete': {},
}
}
}
}
In the above example, we introduced two new predicates which are not
included in the APIMAS standard predicates: a) .drf_collection
, b)
.drf_field
. These predicates are understood only by the
django-rest-framework backend, which is responsible for implementing
this specification.
APIMAS provides a mechanism for setting the permissions of your application. You can read more in a next section. However, for this tutorial, we omit the description of this mechanism. Thus, you have to add the following configuration on your specification.
API_SPEC = {
'api': {
'.endpoint': {
'permissions': [
# That is (collection, action, role, field, state, comment).
('foo', '*', 'anonymous', '*', '*', 'Just an example')
]
},
'foo': {
'.collection': {},
'.drf_collection': {
'model': 'myapp.models.Foo'
},
'*': {
'text': {
'.string': {},
'.drf_field': {},
},
'number': {
'.integer': {},
'.drf_field': {},
},
},
'actions': {
'.list': {},
'.retrieve': {},
'.create': {},
'.update': {},
'.delete': {},
}
}
}
}
This tells APIMAS, that an anonymous user can perform any action (‘*’ on 2nd column) on collection ‘foo’, associated with any field (‘*’ on 4th column) and any state (‘*’ 5th column). The last column is used to write your comments. More about permissions can be found here.
Then, APIMAS will create all required code using DjangoRestAdapter
class. In particular, DjangoRestAdapter
will create the mapping
of URL patterns and views (urlpatterns
). This mapping is
specified specify on your URLconf
module (typically, the
urls.py
file on your django-project).
For example, in urls.py
file:
from apimas.drf.django_rest import DjangoRestAdapter
from myapp.spec import API_SPEC
adapter = DjangoRestAdapter()
adapter.construct(API_SPEC)
urlpatterns = [
adapter.urls
]
Now, you are ready to test your application, by running:
python manage.py runserver
You can make some testing calls using curl
. For example, create a
new resource object
curl -X POST -d '{"text": "foo", "number": 1}' -H "Content-Type: application/json" http://localhost:8000/api/foo/
{
"number": 1,
"text": "foo"
}
or, retrieve an existing one:
curl -X GET http://localhost:8000/api/foo/1/
{
"number": 1,
"text": "foo"
}
So far, we have seen a short tutorial on using APIMAS to create a django application. We easily created an application which served a REST API, by only defining the storage django-models) and the view (APIMAS specification, i.e. API representation) representation of our application. Typically, apart from the django-models, a django-developer has to create the corresponding django forms and views in order to map url patterns with implementation. Hence, for a typical example a developer has to make the following classes:
models.py
:
from django.db import models
class Foo(models.Model):
text = models.CharField(max_length=30)
number = models.IntegerField()
forms.py
from django import forms
from myapp.models import Foo
class FooForm(forms.ModelForm):
class Meta(object):
model = Foo
fields = ('number', 'text',)
views.py
import json
from django.http import HttpResponse
from myapp.forms import FooForm
def view_foo(request):
form = FooForm()
return render(request, 'path/to/template', form)
Even when using django-rest-framework which facilitates the development of the REST API, the developer typically has to create boilerplate such as:
serializers.py
from rest_framework import serializers
from myapp.models import Foo
class FooSerializer(serializers.ModelSerializer):
class Meta:
model = Foo
fields = ('number', 'text')
views.py
from rest_framework import viewsets
from myapp.serializers import FooSerializer
from myapp.models import Foo
class FooViewSet(viewsets.ModelViewSet):
serializer_class = FooSerializer
queryset = Foo.objects.all()
Even though in the above examples things seem to be easy, the management of such an application may become cumbersome if more entities are introduced or the complexity of data representation of an entity is increased, e.g. if we have an entity with 30 fields, and each field behaves differently according to the state of the entity (e.g. non-accessible in read operations).
As already mentioned in a previous section, APIMAS provides a way to
describe your application and its data representation on a document.
The django-rest-adapter reads from the specification and it
translates the description of your application into implementation.
The django-rest-adapter uses django-rest-framework behind the
scenes and generates at runtime the required
rest_framework.serializers.Serializer
(responsible for the
serialization and deserialization of your request data) and
rest_framework.viewsets.ViewSet
classes according to the
specification.
In essence, your application consists of your storage and API representation, and each time, you want to change something on your API representation, you simply refer to the corresponding properties of your specification.
The django-rest adapter creates the corresponding mapping of url patterns to views based on the storage and API representation of your application. Therefore, for a typical application we have the following work flow:
GET <collection name>/
), the list of
objects included in the model associated with the collection, is
retrieved.GET <collection name>/<pk>/
), a single
model instance is displayed based on its API representation.POST <collection name>/
), sent data are
validated, and then a model instance is created after serializing
data.PUT|PATCH <collection name>/pk/
), sent
data are validated, serialized, and the new values of model instance
are set.DELETE <collection name>/pk/
), a model
instance, identified by the <pk>
is deleted.If the default behaviour above does not suit the application, you are able to customize and extent it by adding your own logic. Specifically, APIMAS provides two hooks for every action (before interacting with the database and after) for extending the logic of your application or executing arbitrary code (e.g. executing a query or sending an email to an external agent). You can do this as follows:
from apimas.drf.mixins import HookMixin
class RestOperations(HookMixin):
def preprocess_create(self):
# Code executed after validating data and before creating
# a new instance.
...
def finalize_create(self):
# Code executed after creating the model instance and
# and before serving the response.
...
If you want to customize the behaviour of your application in other actions, you simply have to add the corresponding methods to your class, e.g.
preprocess_<action_name>(self)
(for executing code before
interacting with db)finalize_<action_name>(self)
(for executing code before
serving the response and after interacting with db).Imagine that we have the following model:
from django.db import models
class Foo(models.Model):
text = models.CharField(max_length=30)
number = models.IntegerField()
another_text = models.CharField(max_length=30)
and the API specification for this model:
API_SPEC = {
'api': {
'.endpoint': {},
'foo': {
'.drf_collection': {
'model': 'myapp.models.Foo'
},
'*': {
'text': {
'.string': {},
'.drf_field': {}
},
'number': {
'.integer': {},
'.drf_field': {}
},
},
'actions': {
'.list': {},
'.retrieve': {},
'.create': {},
'.update': {},
'.delete': {}
}
}
}
}
In the above example, the field another_text
is not exposed to the
API, but its value is computed by the server based on the values of
text
and number
. Therefore, in this case, you may write your
hook class like below:
from myapp.mymodule.myfunc
class RestOperations(HookMixin):
def preprocess_create(self):
context = self.unstash()
another_text = myfunc(context.validated_data['text'],
context.validated_data['number'])
self.stash(extra={'another_text': another_value})
Here we get the context of the action via the self.unstash()
method,
then we compute the value of another_text
according to some
application logic, and finally, we tell APIMAS (self.stash()
) that
it should add extra data to the model instance (another_text
),
in addition to those sent by the client.
self.unstash()
returns a namedtuple with the following fields:
instance
: Model instance to interact.data
: Dictionary of raw data, as sent by the client.validated_data
: Dictionary of de-serialized, validated data.extra
: A dictionary with extra data, you wish to add to your
model.response
: Response object.Note that in some cases, there are some context fields that are not
initialized. For instance, in the preprocess_create()
hook,
instance
is not initialized because model instance has not been
created yet.
The last part is to declare the use of the hook class. You have to
provide an argument to the hook_class
parameter of the
.drf_collection
predicate.
'foo': {
'.drf_collection': {
'model': 'myapp.models.Foo',
'hook_class': 'myapp.hooks.RestOperations',
},
# spec as above.
}
As we have already mentioned, django-rest adapter generates dynamically two classes: a) a serializer class, b) a viewset class according to the specification. If you still wish to customize and override these generated classes, APIMAS provides various ways to do that:
There are two primary reasons to do this:
Below, we describe two common cases when you need to write django-rest-framework code.
In your API, you may have structural fields, that is, all fields
characterized as .struct
or .structarray
.
django-rest-framework backend does not support write operations,
because they are read-only by default. Hence, if you want to be able
to perform write operations on these fields, you have to override the
create()
or/and update()
methods, provided by each serializer
class.
Example:
from rest_framework.serializers import BaseSerialzer
class MySerializer(BaseSerializer):
def create(self, validated_data):
# Your code
...
def update(self, instance, validated_data):
# Your code.
...
Then, in your specification, specify the following parameter in
.drf_collection
predicate:
'foo': {
'.drf_collection': {
'model': 'myapp.models.Foo',
'model_serializers': ['myapp.serializers.MySerializer'],
},
# spec as above.
}
model_serializers
tells APIMAS that the classes specified should
be base classes for the generated serializer class, which are placed to
the lowest level of the inheritance hierarchy. Therefore, in the above
example, the hierarchy of the generated class is as follows:
If you specify more than one classes on your model_serializers
,
then the classes on the right will inherit the classes on the left.
Further information about writable structure fields can be found in the official documentation of django-rest-framework, here.
You can have additional actions to your API apart from the CRUD ones you declare in the specification. For example:
POST foo/1/myaction/
To implement myaction
you need to write your own ViewSet class
that includes a method with the action’s name. For instance:
from rest_framework.decorators import detail_route
from rest_framework.viewsets import GenericViewSet
class MyViewSet(GenericViewSet):
@detail_route(methods=['post'])
def myaction(self, request, pk):
# My code.
..
Next, you need to include the module path of your ViewSet mixin class in
the mixins
parameter of your .drf_collection
predicate.
APIMAS will inherit from your class and the extra action method
will appear in the generated final ViewSet class.
'foo': {
'.drf_collection': {
'model': 'myapp.models.Foo',
'mixins': ['myapp.mixins.MyViewSet'],
},
# spec as above.
}
You can find more information about extra actions here.
Note
Specifying bases and mixins for the generated viewse class enhances the resusability of your code. For instance, you may have a custom ViewSet class which is shared amongst all your collections. Instead of copying the same code over and over across different hooks, you can declare a common mixin for all of them within your specification.
By default, the django-rest adapter reads all REST resource properties
predicated with .drf_field
and tries to map each of them to an
attribute or function on your django model.
It is not necessary to have 1 to 1 mapping between your API and storage
configuration. For instance, you may want to:
Examples:
In this example, we create an api_text
property on a REST resource
that is mapped to a differently named text
field on a django model,
using the source
parameter of the .drf_field
predicate:
from django.db import models
class Foo(models.Model):
text = models.CharField(max_length=30)
number = models.IntegerField()
'foo': {
'.drf_collection': {
'model': 'myapp.models.Foo',
},
'*' {
'api_text': {
'.string': {},
'.drf_field': {
'source': 'text'
}
},
'number': {
'.integer': {},
'.drf_field': {},
},
},
}
You can create REST resource properties that are not mapped to any of
the django model fields. In the following example, we add a string
property named “extra_field” to our specification that is not to be
saved to or retrieved from the model, by specifying onmodel: False
to the .drf_field
predicate.
'foo': {
'.drf_collection': {
'model': 'myapp.models.Foo',
},
'*' {
'api_text': {
'.string': {},
'.drf_field': {
'source': 'text'
}
},
'number': {
'.integer': {},
'.drf_field': {},
},
'extra-field': {
'.string': {},
'.drf_field': {
'onmodel': False,
},
},
},
}
A non-model property is validated but there is no automatic handling of it during write actions. You have to handle it via the hooks provided by APIMAS.
When processing read actions such as list or retrieve, the django-rest
adapter will seek to call a function to extract the value of non-model
properties since there is no model for them.
If you want non-model fields to be readable, you must provide an
argument to the instance_source
parameter on the .drf_field
predicate. The parameter is enabled only when onmodel
is False.
instance_source
must be the module path of a function that accepts
a model instance as input and returns the property value.
def myfunc(instance):
# Code which retrieves the value of a non-model field based on
# the instance.
pk = instance.pk
# Open a file, identified by the pk of the instance and
# extract the desired value.
with open('file_%s.txt' % (str(pk)), 'r') as myfile:
data = myfile.read()
return data
'foo': {
'.drf_collection': {
'model': 'myapp.models.Foo',
},
'*' {
'api_text': {
'.string': {},
'.drf_field': {
'source': 'text'
}
},
'number': {
'.integer': {},
'.drf_field': {},
},
'extra-field': {
'.string': {},
'.drf_field': {
'onmodel': False,
'instance_source': 'myapp.mymodule.myfunc'
},
},
},
}
Apart from the things already mentioned, one additional reason for having non-model fields is to create responses with arbitrary structure. For instance, instead of returning the following response:
{
"text": "foo",
"number": 10
}
you wish to return this:
{
"data": {
"text": "foo",
"number": 10
}
}
Your django-model is not aware of the node “data”. Therefore, you need to format your specification as:
'foo': {
'.drf_collection': {
'model': 'myapp.models.Foo',
},
'*' {
'data': {
'.drf_field': {'onmodel': False},
'.struct': {
'api_text': {
'.string': {},
'.drf_field': {
'source': 'text'
}
},
'number': {
'.integer': {},
'.drf_field': {},
},
}
}
},
}
where node “data” is a structured non-model property consisting of model fields “api_text” and “number”.
Warning
All fields on a model must be exposed to the same REST location. They must not be scattered among different nodes in the specification.
APIMAS implements a built-in mechanism for setting permissions to your server-side application. The permissions of your application consist of a set of rules. Each rule contains the following information:
collection
: The name of the collection to which the rule is
applied.action
: The name of the action for which the rule is valid.role
: The role of the user (entity who performs the request)
who is authorized to make request calls.field
: The set of fields that are allowed to be handled in this
request (either for writing or retrieval).state
: The state of the collection which must be valid when
the request is performed.comment
: Any comment for documentation reasons.Consider the following example rule:
rule = ('foo', 'create', 'admin', 'text', 'open', 'section 1.1')
The rule indicates that a request for the collection foo, which is asking to create a new resource, and is issued by an admin, is allowed to create a text property when the collection is in an open state. section 1.1 is a comment made by the developer and it is ignored.
To enable writing another field number, write one more rule:
rule = ('foo', 'create', 'admin', 'text', 'open', 'section 1.1')
rule2 = ('foo', 'create', 'admin', 'number', 'open', 'section 1.1')
or write a pattern to match the two properties:
rule = ('foo', 'create', 'admin', 'text|number', 'open', 'section 1.1')
Supported APIMAS operators for matching are:
*
: Any pattern.?
: Pattern indicated by a regular expression._
: Pattern starts with the given input.!
: NOT operation.&
: AND operation.|
: OR.For example, the following rule reveals that an admin or a member (‘admin|member’) can perform any (‘*’) action any on collection starts wit ‘foo’ (‘_foo’), provided that they handle fields matched with a particular expression (‘?ition$’) and the state is ‘open’ and ‘valid’ at the same time (‘open&valid’).
rule = ('_foo', '*', 'admin|member', '?ition$', 'open&valid', 'section 1.1')
The set of your rules must be declared in your specification as a
parameter to the .endpoint
predicate.
Example:
{
'api': {
'.endpoint': {
'permissions': [
('foo', 'create', 'admin', 'text', 'open', 'section 1.1'),
# More rules...
...
]
}
},
}
In order to check against the roles specified in permission rules, you
have assign to roles to an authenticated user by setting them as a list of
strings named apimas_roles
on your user instance as in:
request.user.apimas_roles = ['admin', 'dev']
class User(models.Model):
...
@property
def apimas_roles(self):
...
Requests by unauthenticated users are matched by the anonymous
role
in permission rules. Using anonymous roles you can make part of your API
public. For example, the following rule allows anyone to create foo
resources as long as foo
is in an open
state:
rule = ('foo', 'create', 'anonymous', '*', 'open', 'section 1.1')
The ‘field’ column of a rule, corresponding to field, indicates which field(s) are allowed to be handled. For instance:
States are matched if calling a class method on the model associated with the request returns true. There is a different method for checking a state for collection (list, create) versus resource requests. The names and signatures of the methods are as follows:
@classmethod
def check_collection_state_<state name>(cls, row, request, view):
# your code. Return True or False.
...
@classmethod
def check_resource_state_<state name>(cls, obj, row, request, view):
# your code. Return True or False.
...
For example, imagine you have the following permission rules:
rule = ('foo', 'create', 'anonymous', '*', 'open', 'section 1.1')
rule2 = ('foo', 'update', 'anonymous', 'number', 'submitted', 'section 1.1')
In the above example, in the case of an update operation, the methods listed below will be triggered to check if states ‘open’ or ‘submitted’ are satisfied:
check_state_collection_open()
check_state_resource_submitted()
If none of the states is matched, then an HTTP_403 error is returned. If only one state is matched, then the django-rest adapter checks which fields can be handled in this state, e.g. when the state is ‘open’, an anonymous user can set all fields, while when the state is ‘submitted’ only the field ‘number’ can be updated.
Below, there is a list of the predicates introduced by the django-rest adapter along with their semantics.
Predicate | Description |
---|---|
.drf_collection |
The parent node is a collection of resources of the same type, where each resource can be related to other resources, it is described by some data, and there are actions that can be performed on it. The parent node uses django-rest-framework backend.
|
.drf_field |
The parent node is a drf_field. In other words, it is an instance of a django-rest-framework field which is responsible for converting raw value of a field (sent by client) into complex data such as objects, querysets, etc.
|