├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── README.md ├── django_api_tools ├── APIModel.py ├── APIView.py ├── __init__.py └── tests │ ├── __init__.py │ ├── conf │ ├── __init__.py │ ├── settings.py │ └── wsgi.py │ ├── fixtures │ ├── __init__.py │ ├── bar_baz_qux.json │ └── user_testprofile_foo.json │ ├── models.py │ ├── tests.py │ ├── urls.py │ └── views.py ├── manage.py ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .idea/ 3 | venv/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | install: 3 | - pip install django 4 | script: 5 | - python manage.py test -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) <2015> 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django API Tools # 2 | 3 | *Quickstart Guide can be found here.* 4 | 5 | ## Overview ## 6 | 7 | *Django API Tools is an add-on which allows developers to run RESTful APIs alongside websites using Forms/Templates.* 8 | 9 | **Features:** 10 | 11 | * Simple integration with an existing project - choose a subset of models to be served by the API. 12 | * RESTful JSON endpoints created for your chosen Django models - no need to hard code URLs. 13 | * Endpoint routing to application specific logic. GET (Read), POST (Create, Update, Deactivate) operations supported by default. 14 | * Supports the addition of custom endpoints. 15 | * Authentication via Django's user auth mechanism; supports login/logout operations as well as custom user permissions on models. 16 | 17 | 18 | # APIView # 19 | 20 | APIView is the add-on's custom class based view. RESTful endpoints are automatically created for each model registered with APIView *(note: these models must subclass APIModel.)*. This allows APIView to take care of routing and responding to requests. A description of the set of URLs created for each registered model can be found in the section RESTful URLs below. 21 | 22 | The add-on offers developers the flexibility in choosing database models to be exposed by the API, and at a lower level, individual model attributes that can be exposed *(see Defining Ownership below)*. 23 | 24 | ** We can write views.py using APIView as below:** 25 | 26 | ```python 27 | 28 | from django_api_tools.APIView import APIView 29 | from example.models import Foo, Bar, Profile 30 | 31 | 32 | class ExampleAPIView(APIView): 33 | # models can map to custom endpoint names 34 | registered_endpoints = { 35 | 'f': Foo, 36 | 'bar': Bar, 37 | 'profile': Profile 38 | } 39 | 40 | # Optional attribute: 41 | # models which allow instances to be created without authentication 42 | public_create_endpoints = (Profile, ) 43 | 44 | # Optional attribute: 45 | # models which allow instances to be updated without authentication 46 | public_update_endpoints = (Bar, ) 47 | 48 | # The statement evaluated upon a successful user login # This should be a subclass of APIModel which can be dictified and returned as JSON 49 | return_on_login = 'user.profile' 50 | ``` 51 | 52 | ** And add support for our new API in urls.py: ** 53 | 54 | ```python 55 | from django.conf.urls import patterns, include, url 56 | from django.views.decorators.csrf import ensure_csrf_cookie 57 | from example.views import PollsAPIView 58 | 59 | # An arbitrary URL prefix, which if matched, forwards requests to the APIView 60 | API_PREFIX = 'api' 61 | 62 | urlpatterns = patterns('', 63 | . 64 | . 65 | . 66 | url(r'^{}/'.format(API_PREFIX), ensure_csrf_cookie(ExampleAPIView.as_view())), 67 | ) 68 | ``` 69 | 70 | ### Logging in ### 71 | ``` POST /api/login/ ``` requires the parameters **username** and **password**. If the login is unsuccessful, either as a result of incorrect credentials or an inactive user, the API will return an empty 404 response. A successful login will both set an authenticated session and *dictify* the eval()'d **return_on_login** variable. 72 | 73 | **return_on_login**: In many web applications, it is common for a user's profile page to be displayed after a successful login. However, the return_on_login variable provides flexibility over what model should be dictified after a successful login. Note that the model returned must be a subclass of APIModel. 74 | 75 | ### Logging out ### 76 | ``` POST /api/logout/ ``` will delete the user's authenticated session (if logged in), and return an empty 200 response. 77 | 78 | ### Public Endpoints ### 79 | Applications often require users to sign up. By default, the add-on requires authentication to create or update a model instance. With this default behaviour, APIView would reject any sign up requests, as registering users would require authentication to complete the process. APIView provides a work around for these kinds of scenarios. Any models registered in **public_create_endpoints** and **public_update_endpoints** are immune from the default behaviour, and allow the public to create or update instances belonging to the models registered. 80 | 81 | ### RESTful URLs ### 82 | 83 | Each model registered with APIView is automatically provided the following URLs *(assuming that the API_PREFIX is 'api')*: 84 | 85 | **Get a list of resource instances:** 86 | 87 | ```GET /api//?page=n ``` 88 | 89 | *Where n is an optional integer parameter defaulting to 1.* 90 | 91 | Each page of results will return, by default, up to 10 resource instances. Where less than 10 instances are returned for a given page, it is safe to assume that there are no more pages. 92 | 93 | *Returns:* 94 | 95 | * An array of **short** dictionary objects. 96 | * An empty 404 response for an invalid page number or a request to a non-existent page. 97 | 98 | **Get an individual resource:** 99 | 100 | ``` GET /api/// ``` 101 | 102 | *Where instance is a unique identifier handled by the specific endpoint. By default, the identifier is treated as a Django model internal id.* 103 | 104 | *Returns:* 105 | 106 | * A **long** dictionary representation of the model instance. 107 | * An empty 404 response if a model instance can't be found, and a custom request mapping can't be found. 108 | 109 | *Notes:* 110 | 111 | If no instance is found, APIView will treat the request as a custom request on the endpoint model. It will route the request to the model's ```api_custom_request(cls, request) ``` classmethod. You are free to implement custom requests however you wish. 112 | 113 | **Custom request Type 2:** 114 | 115 | ```GET /api/////.../.../``` 116 | 117 | *Where there can be an arbitrary number of custom fields.* 118 | 119 | *Returns:* 120 | 121 | * An empty 404 response by default, unless code handling the custom request has been implemented. 122 | 123 | **Create a new resource:** 124 | 125 | ```POST /api//``` 126 | 127 | *Notes:* 128 | 129 | The create request is routed to the endpoint's implementation of ``api_create(cls, request)``. 130 | 131 | *Returns:* 132 | 133 | * A **long** dictionary representation of the created model instance. 134 | * An empty 404 response if an instance could not be created. 135 | 136 | **Update an existing resource:** 137 | 138 | ```POST /api///``` 139 | 140 | *Notes:* 141 | 142 | The update request is routed to the endpoint's implementation of ``api_update(self, request)``. 143 | 144 | *Returns:* 145 | 146 | * A **long** dictionary representation of the updated model instance. 147 | * An empty 404 response if updating failed. 148 | 149 | 150 | # APIModel # 151 | APIModel is the add-on's custom abstract model class. Each model registered with APIView must be a subclass of APIModel and implement the abstract methods *is_owner*, *api_create* and *api_update*. 152 | 153 | 154 | APIModel handles the *dictification* of instances, that is, creating a dictionary representation of the model instance *(see Dictification for a more detailed explanation*). 155 | 156 | ## Defining Ownership ## 157 | Django API Tools allow model attributes to be divided into three authentication levels: 158 | 159 | * Those that can be viewed by the public 160 | * Those that only registered users of the application can view 161 | * Those that can only be viewed by the owner(s) of a particular model instance. 162 | 163 | Django API Tools use Django's standard user authentication system. Thus, much of the authentication revolves around extracting the **request_user** (request.user) and testing the users' authentication level against a model instance. 164 | 165 | Django API Tools assumes that the underlying application can deduce, from the request user, the ownership status that particular user has over a given model instance. 166 | 167 | **We highly recommend that your application has a model (inheriting from APIModel) with a 1:1 mapping to User.** 168 | 169 | In the examples below we call this model *Profile*: 170 | ```python 171 | class Profile(APIModel): 172 | user = models.OneToOneField(User, unique=True, related_name='profile') 173 | ``` 174 | 175 | Having this kind of setup provides a mechanism for extending Django's user model; we can add application-specific fields to Profile, such as *eye_colour*, which can then be extracted from a request via the notation ```request.user.profile.eye_colour```. Furthermore, having this setup provides a basis for authenticating "owners" of model instances. 176 | 177 | Sub-classes of APIModel must implement the abstract method ```is_owner(self, request_user)```. 178 | 179 | ### Example 1: No ownership ### 180 | 181 | Suppose we he had an application with a "Wall" which could be read from, written to, and modified by the public. 182 | In this scenario, for any user, APIModel would always return True. 183 | 184 | ```python 185 | class Wall(APIModel): 186 | . 187 | . 188 | . 189 | def is_owner(self, request_user): 190 | return True 191 | ``` 192 | 193 | ### Example 2: Individual user ownership ### 194 | 195 | Suppose our application was changed, such that each "Wall" now had an individual owner. 196 | We could change ```is_owner``` to return True if the requesting user is the owner of the Wall. 197 | 198 | ```python 199 | class Profile(APIModel): 200 | user = models.OneToOneField(User, unique=True, related_name='profile') 201 | 202 | 203 | class Wall(APIModel): 204 | owner = models.ForeignKey(Profile, related_name='walls') 205 | . 206 | . 207 | . 208 | def is_owner(self, request_user): 209 | return request_user.profile == self.owner 210 | ``` 211 | 212 | ### Example 3: Group ownership ### 213 | 214 | We could change our application further so that each "Wall" is owned by a "Group", and that Groups consist of Profiles. 215 | ```is_owner``` would now return True if the requesting user is a member of the Group which owns the Wall. 216 | 217 | ```python 218 | class Group(APIModel): 219 | . 220 | . 221 | . 222 | 223 | class Profile(APIModel): 224 | user = models.OneToOneField(User, unique=True, related_name='profile') 225 | group = models.ForeignKey(Group, related_name='members') 226 | 227 | class Wall(APIModel): 228 | owner = models.ForeignKey(Profile, related_name='walls') 229 | group = models.ForeignKey(Group, related_name='walls') 230 | . 231 | . 232 | . 233 | def is_owner(self, request_user): 234 | return request_user.profile.group == self.group 235 | ``` 236 | 237 | ## Dictification ## 238 | 239 | Dictification is the process of creating a dictionary representation of a model instance. 240 | Although this process is largely taken care of by the underlying APIModel class, subclasses of APIModel must specify what attributes should be used. 241 | 242 | ### Long & Short Dictionaries ### 243 | 244 | There are two types of dictification: 245 | 246 | * Short - A partial dictionary representation of an object. Only dictifies attributes listed in the *short_description_fields*. 247 | * Long - A full dictionary representation of an object. Dictifies attributes listed in both *short_description_fields* and *long_description_fields*. 248 | 249 | ```python 250 | class Choice(APIModel): 251 | text = models.CharField(max_length=200) 252 | votes = models.IntegerField(default=0) 253 | 254 | short_description_fields = (id, ) 255 | long_description_fields = (text, votes, ) 256 | 257 | ``` 258 | 259 | ``` 260 | c = Choice.obects.create(text='foo') 261 | 262 | c.dictify_short() 263 | >> {"id":1} 264 | 265 | c.dictify_long() 266 | >> {"id": 1, "text": "foo", "votes": 0} 267 | ``` 268 | 269 | ### Foreign Model attributes ### 270 | 271 | In a typical web application, some models will be related to other models. 272 | API add-on supports both short and long dictification of related models: 273 | 274 | * Related - prefix "rel_short" or "rel_long" 275 | * One-to-One - prefix "onetoone_short" or "onetoone_long" 276 | * Foreign Key - prefix "fk_short" or "fk_long" 277 | * Many-to-Many - prefix "m2m_short" or "m2m_long" 278 | 279 | ```python 280 | class Choice(APIModel): 281 | text = models.CharField(max_length=200) 282 | votes = models.IntegerField(default=0) 283 | 284 | short_description_fields = (id, ) 285 | long_description_fields = (text, votes, rel_long_foos) 286 | 287 | class Foo(APIModel): 288 | choice = models.ForeignKey(Choice, related_name='foos') 289 | text = models.CharField(max_length=200) 290 | 291 | short_description_fields = (id, ) 292 | long_description_fields = (text, fk_long_choice) 293 | ``` 294 | 295 | Upon dictification, APIModel would recognise the attribute *rel_long_bars* as a related key named bars, and create a long dictification of the related model. If instead the attribute was named *rel_short_bars*, the related model would only be dictified as a short dictionary. 296 | 297 | ```python 298 | c = Choice.objects.create(text='foo') 299 | f = Foo.objects.create(text='foo', choice=c) 300 | 301 | f.dictify_short() 302 | >> {"id":1} 303 | 304 | f.dictify_long() 305 | >> {"id": 1, "text": "foo", 306 | "choice": {"id", "text": "foo", "votes": 0} 307 | } 308 | 309 | c.dictify_short() 310 | >> {"id": 1} 311 | 312 | c.dictify_long() 313 | >> {"id", "text": "foo", "votes": 0, 314 | "foos":[ 315 | {"id": 1, "text": "foo"} 316 | ] 317 | } 318 | 319 | ``` 320 | 321 | 322 | # Quickstart Guide # 323 | 324 | ``` sudo pip install django_api_tools ``` 325 | 326 | The quickstart guide adapts the model setup found in the Django Tutorial, illustrating how you can set up and use Django API Tools in an application. 327 | 328 | We adapt **views.py** to use the Class-based APIView: 329 | ```python 330 | 331 | from django_api_tools.APIView import APIView 332 | from polls.models import Question, Choice, Profile 333 | 334 | 335 | class PollsAPIView(APIView): 336 | # models can map to custom endpoint names 337 | registered_endpoints = { 338 | 'question': Question, 339 | 'choice': Choice, 340 | 'profile': Profile 341 | } 342 | 343 | # models which allow instances to be created by the public 344 | public_create_endpoints = (Profile, ) 345 | 346 | # models which allow instances to be updated by the public 347 | public_update_endpoints = (Choice, ) 348 | 349 | # the endpoint instance whose JSON dictionary should be returned upon a successful user login 350 | return_on_login = 'user.profile' 351 | ``` 352 | 353 | We next hook up the **urls.py** to our class-based view: 354 | 355 | ```python 356 | from django.conf.urls import patterns, include, url 357 | from django.views.decorators.csrf import ensure_csrf_cookie 358 | from polls.views import PollsAPIView 359 | 360 | urlpatterns = patterns('', 361 | url(r'^api/', ensure_csrf_cookie(PollsAPIView.as_view())), 362 | ) 363 | ``` 364 | 365 | *Note that your API URLs don't necessarily have to start with /api/.* 366 | 367 | Finally, we must create our **models.py** 368 | 369 | ```python 370 | from datetime import datetime 371 | 372 | from django_api_tools.APIModel import APIModel 373 | 374 | from django.db import models 375 | from django.contrib.auth.models import User 376 | 377 | class Profile(APIModel): 378 | user = models.OneToOneField(User, unique=True, related_name='profile') 379 | 380 | def is_owner(self, request_user): 381 | return request_user.profile == self.user 382 | 383 | @classmethod 384 | def api_create(cls, request): 385 | # first create a user 386 | user = User.objects.create(username=request.POST['username', email=request.POST['email'], password=request.POST['password']) 387 | 388 | # next create the profile object 389 | profile = Profile.objects.create(user=user) 390 | 391 | # login code 392 | user = authenticate(username=user.username, password=user.password) 393 | login(request, user) 394 | 395 | # return the object to be dictified 396 | return profile 397 | 398 | def api_update(self, request): 399 | # APIModel provides default code for deactivation, 400 | # so it's wise to call the super api_update even 401 | # if you don't have any update logic yourself 402 | if self.is_owner(request.user): 403 | return super(Profile, self).api_update(request) 404 | 405 | # Empty return if the request user didn't have proper permissions 406 | return None 407 | 408 | class Question(APIModel): 409 | question_text = models.CharField(max_length=200) 410 | pub_date = models.DateTimeField('date published') 411 | 412 | public_fields = ('id', 'question_text', 'pub_date') 413 | 414 | def is_owner(self, request_user): 415 | # No ownership in this basic model setup, so everyone is treated as an owner 416 | return True 417 | 418 | @classmethod 419 | def api_create(cls, request): 420 | question = Question.objects.create(question_text=request.POST['question'], pub_date=datetime.now()) 421 | return question 422 | 423 | def api_update(self, request): 424 | # APIModel provides default code for deactivation, 425 | # so it's wise to call the super api_update even 426 | # if you don't have any update logic yourself 427 | if self.is_owner(request.user): 428 | return super(Profile, self).api_update(request) 429 | 430 | # Empty return if the request user didn't have proper permissions 431 | return None 432 | 433 | class Choice(APIModel): 434 | question = models.ForeignKey(Question) 435 | choice_text = models.CharField(max_length=200) 436 | votes = models.IntegerField(default=0) 437 | owner = models.ForeignKey(Profile, related_name='choices') 438 | 439 | def is_owner(self, request_user): 440 | return request_user.profile == self.owner 441 | 442 | @classmethod 443 | def api_create(cls, request): 444 | choice = Choice.objects.create(choice_text=request.POST['text'], owner=request.user) 445 | return choice 446 | 447 | def api_update(self, request): 448 | if request.POST['vote']: 449 | self.votes += 1 450 | if self.is_owner(request.user): 451 | return super(Profile, self).api_update(request) 452 | 453 | # Empty return if the request user didn't have proper permissions 454 | return None 455 | ``` 456 | -------------------------------------------------------------------------------- /django_api_tools/APIModel.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from datetime import datetime 3 | 4 | from django.db import models 5 | from django.core.paginator import Paginator 6 | 7 | __author__ = 'szpytfire' 8 | 9 | class UserAuthCode(object): 10 | """ 11 | Maintains verbose representations of the authorisation levels used 12 | in APIModel 13 | """ 14 | PUBLIC = 1 15 | REGISTERED_USER = 2 16 | OWNER = 3 17 | 18 | class ReservedPrefix(object): 19 | """ 20 | Maintains the foreign model prefixes which are supported by APIModel 21 | """ 22 | FK_SHORT = 'fk_short' 23 | FK_LONG = 'fk_long' 24 | REL_SHORT = 'rel_short' 25 | REL_LONG = 'rel_long' 26 | ONE_TO_ONE_SHORT = 'onetoone_short' 27 | ONE_TO_ONE_LONG = 'onetoone_long' 28 | MANY_TO_MANY_SHORT = 'm2m_short' 29 | MANY_TO_MANY_LONG = 'm2m_long' 30 | 31 | class APIModel(models.Model): 32 | """ 33 | Abstract Model which all API endpoints must inherit from. 34 | """ 35 | 36 | # Deactivating a model is a common RESTful task 37 | # APIModel provides activte-related fields and handles deactivation 38 | # by default 39 | active = models.IntegerField(default=1) 40 | date_deactivated = models.DateTimeField(null=True) 41 | 42 | # Model fields which are publicly readable via the API 43 | public_fields = () 44 | # Model fields which are only exposed to registered users 45 | registered_user_fields = () 46 | # Model fields which are only exposed to owner(s) of the model instance 47 | # Ownership is determined by the implementation of is_owner() 48 | owner_only_fields = () 49 | 50 | # Fields to be exposed when a short summary of the model instance is required 51 | short_description_fields = () 52 | # Fields to be exposed when a full description of the model instance is required 53 | long_description_fields = () 54 | 55 | # The default number of model instances to return in a get_all() request 56 | pagination = 10 57 | 58 | # The default readability of the model instance is set to a Public User 59 | _user_auth = UserAuthCode.PUBLIC 60 | 61 | # Prefixes which are used in field descriptions to indicate foreign model relationships 62 | _reserved_prefixes = [ 63 | ReservedPrefix.FK_SHORT, 64 | ReservedPrefix.FK_LONG, 65 | ReservedPrefix.REL_SHORT, 66 | ReservedPrefix.REL_LONG, 67 | ReservedPrefix.ONE_TO_ONE_SHORT, 68 | ReservedPrefix.ONE_TO_ONE_LONG, 69 | ReservedPrefix.MANY_TO_MANY_SHORT, 70 | ReservedPrefix.MANY_TO_MANY_LONG 71 | ] 72 | 73 | def dictify(self, fields_to_include, ommit_related_fields): 74 | """ 75 | Initiates the dictification process on the model instance using the fields passed in. 76 | Goes through each auth level up to the current user's auth level 77 | and calls dictify_helper with the fields corresponding to that level. 78 | 79 | Concatenates the result dictionaries together and returns. 80 | 81 | :param fields_to_include: Either the long or short description fields 82 | :param ommit_related_fields: Boolean denoting whether or not to dictify the related model fields. 83 | :return: A dictionary representation of the current instance. 84 | """ 85 | dictified_fields = {} 86 | 87 | # always include public fields 88 | dictified_fields.update(self.dictify_helper(self.public_fields, fields_to_include, ommit_related_fields)) 89 | 90 | # conditionally include registered user/owner fields 91 | if self._user_auth >= UserAuthCode.REGISTERED_USER: 92 | dictified_fields.update(self.dictify_helper(self.registered_user_fields, fields_to_include, ommit_related_fields)) 93 | 94 | if self._user_auth == UserAuthCode.OWNER: 95 | dictified_fields.update(self.dictify_helper(self.owner_only_fields, fields_to_include, ommit_related_fields)) 96 | 97 | return dictified_fields 98 | 99 | def dictify_helper(self, auth_level_fields, fields_to_include, ommit_related_fields): 100 | """ 101 | Performs the actual dictification. 102 | Decides whether or not, based on the authentication level, whether the field should be 103 | added to the object dictionary. 104 | If the attribute begins with a reserved prefix (indicating a related model), 105 | it will dictify the foreign model and add it to the dictionary. 106 | 107 | :param auth_level_fields: Object fields to include for a given authentication level 108 | :param fields_to_include: Either short description or long description fields 109 | :param ommit_related_fields: Boolean to denote wehther or not to dictify related models 110 | :return: 111 | """ 112 | 113 | # dictionary representation of the object 114 | dictified_fields = {} 115 | 116 | # go through each field and add it to the dictionary 117 | # if it's in list of fields for the authentication level 118 | for field in fields_to_include: 119 | if field in auth_level_fields: 120 | # extract the reserved prefix 121 | prefix = filter(lambda prefix: field.startswith(prefix), self._reserved_prefixes) 122 | 123 | # if the field doesn't begin with a reserved prefix 124 | # get the regular attribute/property 125 | if not prefix: 126 | dictified_fields[field] = getattr(self, field, None) 127 | 128 | # do something special with the related model field 129 | # if we're allowed to dictify related models 130 | elif prefix and not ommit_related_fields: 131 | # separate the prefix and actual related field name 132 | prefix = prefix[0] 133 | relation = field[len(prefix) + 1:] 134 | 135 | # try and get the related model 136 | val = getattr(self, relation, None) 137 | 138 | # ommit the field if we can't find the related model 139 | if val is None: 140 | dictified_fields[relation] = None 141 | 142 | # Perform a different dictification depending on what reserved prefix is used 143 | # foreign models denoted by the related SHORT prefixes will be dictified short 144 | # and similarly, related LONG will force a full dictification 145 | # REL fields denote a one to many relationship. Thus, the corresponding models will be 146 | # dictified into a list 147 | elif prefix in [ReservedPrefix.FK_SHORT, ReservedPrefix.ONE_TO_ONE_SHORT]: 148 | dictified_fields[relation] = val.dictify_with_auth(self._curr_user, ommit_related_fields=True) 149 | elif prefix in [ReservedPrefix.FK_LONG, ReservedPrefix.ONE_TO_ONE_LONG]: 150 | dictified_fields[relation] = val.dictify_with_auth(self._curr_user, short_dict=False, ommit_related_fields=True) 151 | elif prefix in [ReservedPrefix.REL_SHORT, ReservedPrefix.MANY_TO_MANY_SHORT]: 152 | dictified_fields[relation] = [rel.dictify_with_auth(self._curr_user, ommit_related_fields=True) for rel in val.all()] 153 | elif prefix in [ReservedPrefix.REL_LONG, ReservedPrefix.MANY_TO_MANY_LONG]: 154 | dictified_fields[relation] = [rel.dictify_with_auth(self._curr_user, short_dict=False, ommit_related_fields=True) for rel in val.all()] 155 | 156 | return dictified_fields 157 | 158 | def dictify_short(self, ommit_related_fields): 159 | """ 160 | Dictifies only short description fields. 161 | 162 | :param ommit_related_fields: Boolean to determine whether to dictify related models 163 | :return: A short dictionary description of the model instance 164 | """ 165 | return self.dictify(self.short_description_fields, ommit_related_fields) 166 | 167 | def dictify_long(self, ommit_related_fields): 168 | """ 169 | Dictifies both short and long description fields. 170 | 171 | :param ommit_related_fields: Boolean to determine whether to dictify related models 172 | :return: A full dictionary description of the model instance 173 | """ 174 | return self.dictify(self.short_description_fields + self.long_description_fields, ommit_related_fields) 175 | 176 | def dictify_with_auth(self, user, short_dict=True, ommit_related_fields=False): 177 | """ 178 | Sets the authentication level on the model instance 179 | before dictifying. 180 | 181 | :param user: The request user 182 | :param short_dict: By default, only creates a short dictification. 183 | :param ommit_related_fields: By default allows dictifying of related models. 184 | :return: A dictionary representation of the model instance 185 | """ 186 | 187 | # If the instance has been deactivated, the API will treat the resource as if it doesn't exist 188 | if not self.active: 189 | return None 190 | 191 | self.set_user_auth(user) 192 | 193 | return self.dictify_short(ommit_related_fields) if short_dict else self.dictify_long(ommit_related_fields) 194 | 195 | @classmethod 196 | def get_all(cls, page_number, user): 197 | """ 198 | Dictifies endpoint model instances for the given page number. 199 | Returns up to the number of instances specified by the pagination variable. 200 | Dictifies the instances with dictify_with_auth which takes into consideration 201 | the user being passed in. 202 | 203 | :param page_number: The page number given to the paginator 204 | :param user: The request user 205 | :return: A list of short dictified model instances 206 | """ 207 | objects = cls.objects.filter(active=1) 208 | p = Paginator(objects, cls.pagination) 209 | return [object.dictify_with_auth(user) for object in p.page(page_number).object_list] 210 | 211 | @classmethod 212 | def get_model_instance(cls, rest_param): 213 | """ 214 | Provides a default implementation for getting an endpoint instance by its ID. 215 | 216 | :param rest_param: The endpoint instance criteria which has been passed in via the format: 217 | /// 218 | :return: The endpoint instance, or raises an ObjectDoesNotExist exception 219 | """ 220 | return cls.objects.get(id=rest_param) 221 | 222 | @abstractmethod 223 | def is_owner(self, request_user): 224 | """ 225 | This method has been left intentionally abstract to allow custom ownership of models. 226 | For example, a particular model may be owned by multiple users, or by a specific group of users. 227 | 228 | :param request_user: reference to request.user 229 | :return: True if the user has owner rights on the model 230 | """ 231 | raise NotImplementedError 232 | 233 | def set_user_auth(self, user): 234 | """ 235 | The default permission level set on all models is Public. 236 | This method takes in a user object and raises the permission 237 | level accordingly. 238 | 239 | :param user: The request user object 240 | :return: None 241 | """ 242 | self._curr_user = user 243 | if user.is_authenticated(): 244 | self._user_auth = UserAuthCode.REGISTERED_USER 245 | 246 | if self.is_owner(user): 247 | self._user_auth = UserAuthCode.OWNER 248 | 249 | @classmethod 250 | def api_create(cls, request): 251 | """ 252 | Allows a model instance to be created via the API. 253 | 254 | By default the model is set to read only (the method returns None, 255 | which at an APIView level is exposed as a 404 error). 256 | 257 | Any model wishing to allow creation should override this method, 258 | making use of the request parameter. 259 | 260 | :param request: The request object 261 | :return: The newly created object (or None if one wasn't created). 262 | """ 263 | return None 264 | 265 | def api_update(self, request): 266 | """ 267 | Allows a model instance to be updated via the API. 268 | 269 | By default, it handles the process of deactivating a model instance, 270 | by looking out for the 'deactivate' parameter in the request. 271 | 272 | It also handles the save of any updates and returning the updated model. 273 | 274 | If this method is overwritten by a subclass, the subclass should call 275 | super api_update() at the END of the overwriting method to make use of this logic. 276 | 277 | Alternatively, subclasses can fully overwrite the method as they please. 278 | 279 | :param request: The request object 280 | :return: Updated instance of the model instance 281 | """ 282 | if self.is_owner(request.user) and request.POST.get('deactivate'): 283 | self.active = 0 284 | self.date_deactivated = datetime.now() 285 | 286 | self.save() 287 | 288 | return self if self.active else None 289 | 290 | @classmethod 291 | def api_custom_request(cls, request): 292 | """ 293 | Any "custom request" sent to the API for an endpoint will be 294 | forwarded to this method. Any model overriding this method 295 | will have access to the whole request so that it determine how to 296 | serve the custom request. 297 | 298 | The method defaults to returning False, which at an API level 299 | translates to the endpoint not supporting custom requests by default. 300 | 301 | :param request: The request object 302 | :return: Default to False, which propagates up as a 404 error.s 303 | """ 304 | return False 305 | 306 | class Meta: 307 | abstract = True -------------------------------------------------------------------------------- /django_api_tools/APIView.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.views.generic import View 4 | from django.http import JsonResponse 5 | from django.core.paginator import EmptyPage, PageNotAnInteger 6 | from django.core.exceptions import ObjectDoesNotExist 7 | from django.contrib.auth import authenticate, login, logout 8 | 9 | __author__ = 'szpytfire' 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class StatusCode(object): 15 | """ 16 | Maintains verbose representations of HTTP status codes. 17 | """ 18 | OK = 200 19 | NOT_FOUND = 404 20 | UNAUTHORIZED = 401 21 | 22 | class ReservedURL(object): 23 | """ 24 | Maintains a list of reserved API urls. 25 | """ 26 | LOGIN = 'login' 27 | LOGOUT = 'logout' 28 | CSRFTOKEN = 'csrftoken' 29 | 30 | @classmethod 31 | def all(cls): 32 | """ 33 | Get all the reserved API urls 34 | 35 | :return: reserved API urls 36 | """ 37 | return (cls.LOGIN, cls.LOGOUT, cls.CSRFTOKEN) 38 | 39 | class UnsafeJSONResponse(JsonResponse): 40 | """ 41 | Subclass of JSONResponse which provides some default parameters: 42 | - Sets the Status code to 200 43 | - Sets the safe parameter to False, allowing non-dictionary objects to be returns as JSON 44 | """ 45 | def __init__(self, data, status=StatusCode.OK): 46 | super(UnsafeJSONResponse, self).__init__(status=status, data=data, safe=False) 47 | 48 | class BadJSONResponse(UnsafeJSONResponse): 49 | """ 50 | Subclass of UnsafeJSONResponse which by default outputs no response on a bad request 51 | """ 52 | def __init__(self, status, data=None): 53 | super(BadJSONResponse, self).__init__(status=status, data=data) 54 | 55 | class APIUrl(object): 56 | """ 57 | Provides URL validation. 58 | Splits the requesting URL into separate components and figures out what type 59 | of API call the request is. 60 | 61 | Extracts the endpoint model, model instance (if any), and custom request fields. 62 | Alternatively maps to a reserved URL. 63 | If no match is made, it deems the URL an invalid request. 64 | """ 65 | 66 | # The endpoint model extracted from the request URL 67 | REQUESTED_MODEL = None 68 | # The endpoint model instance extracted from the request URL 69 | REQUESTED_MODEL_INSTANCE = None 70 | # Any fields extracted from the URL which are custom request fields 71 | ADDITIONAL_FIELDS = list() 72 | # The reserved URL matched to 73 | RESERVED_URL = None 74 | # A list of the reserved API urls 75 | RESERVED_URLS = ReservedURL.all() 76 | 77 | def __init__(self, request): 78 | """ 79 | Splits the request URL on initialisation 80 | :param request: HTTP request object 81 | :return: None 82 | """ 83 | self.split_url_components(request) 84 | 85 | def split_url_components(self, request): 86 | """ 87 | Splits the URL into separate components for URL validation. 88 | 89 | We assume that API urls follow the structure: 90 | /api/////.... 91 | 92 | Where: 93 | - endpoint is the name of the model, or the custom name given to the model 94 | - instance_criteria is an id of an instance of the model or any other unique field 95 | - custom fields are handled by the specific model 96 | :param request: HTTP request object 97 | :return: None 98 | """ 99 | 100 | 101 | url_components = request.path.split("/") 102 | 103 | # we start from position 2, as for a valid url, 104 | # the first component will be a '' empty string, 105 | # and the second component 'api'. 106 | for i in range(2, len(url_components)): 107 | if url_components[i] == '': 108 | continue 109 | 110 | # if the request is for a reserved URL, or an specifies an endpoint 111 | # model, this will be found at position 2 112 | if i == 2: 113 | # Check first if it's a reserved URL - if it is, there's no point 114 | # continuing with the looping process 115 | if url_components[i] in self.RESERVED_URLS: 116 | self.RESERVED_URL = url_components[i] 117 | break 118 | # if it's not a reserved url, it must be a requested model 119 | self.REQUESTED_MODEL = url_components[i] 120 | elif i == 3: 121 | self.REQUESTED_MODEL_INSTANCE = url_components[i] 122 | else: 123 | self.ADDITIONAL_FIELDS.append(url_components[i]) 124 | 125 | def is_valid_request(self): 126 | """ 127 | Returns whether or not the request was valid: 128 | - request either needs to have pointed to an endpoint or a reserved URL 129 | :return: Boolean depending on whether the URL request was valid 130 | """ 131 | return self.REQUESTED_MODEL is not None or self.RESERVED_URL is not None 132 | 133 | def is_reserved_url(self): 134 | """ 135 | Returns whether or not the request was for a reserved URL: 136 | :return: Boolean depending on whether the URL request was for a reserved URL 137 | """ 138 | return self.RESERVED_URL 139 | 140 | def is_model_request(self): 141 | """ 142 | Returns whether or not the request was an endpoint 'all' request: 143 | - request needs to have pointed to an endpoint but not an instance 144 | - and cannot have any additional/custom request fields 145 | :return: Boolean depending on whether the URL request was an endpoint 'all' request 146 | """ 147 | return self.REQUESTED_MODEL and self.REQUESTED_MODEL_INSTANCE is None and not self.ADDITIONAL_FIELDS 148 | 149 | def is_model_instance_request(self): 150 | """ 151 | Returns whether or not the request was a model instance request: 152 | - request needs to have pointed to an endpoint and an endpoint instance 153 | - but not have custom fields 154 | :return: Boolean depending on whether the URL request was an endpoint instance request 155 | """ 156 | return self.REQUESTED_MODEL_INSTANCE and not self.ADDITIONAL_FIELDS 157 | 158 | def is_custom_request(self): 159 | """ 160 | Returns whether or not the request was custom: 161 | :return: Boolean depending on whether the URL request was for a custom request 162 | """ 163 | return bool(self.ADDITIONAL_FIELDS) 164 | 165 | 166 | class APIView(View): 167 | """ 168 | Class-Based view which provides generic handlers for GET/POST requests. 169 | """ 170 | 171 | # Endpoints which the API allows access to 172 | registered_endpoints = {} 173 | # Endpoints which the API allows updating without user authentication 174 | public_update_endpoints = () 175 | # Endpoints which the API allows creating without user authentication 176 | public_create_endpoints = () 177 | 178 | # A string eval'd upon a successful login 179 | # This should contain a subclass of APIModel which can 180 | # be dictified and returned when a successful login occurs 181 | return_on_login = None 182 | 183 | def get(self, request, *args, **kwargs): 184 | """ 185 | Provides a custom implementation for the standard get() method of a class based view 186 | :param request: the request object 187 | :param args: 188 | :param kwargs: 189 | :return: A JSONResponse object with a 200 status code if the request was valid, 190 | or 404 on an invalid request. 191 | """ 192 | if not self._validate_request(request): 193 | return self.bad_request 194 | 195 | if self._url_validator.is_reserved_url(): 196 | return self._handle_reserved_url_request(request) 197 | 198 | if self._url_validator.is_model_request(): 199 | return self._get_all(request) 200 | 201 | if self._url_validator.is_model_instance_request(): 202 | return self._get_instance(request) 203 | 204 | if self._url_validator.is_custom_request(): 205 | return self.handle_custom_request(request) 206 | 207 | return self.bad_request 208 | 209 | def post(self, request, *args, **kwargs): 210 | """ 211 | Provides a custom implementation for the standard post() method of a class based view 212 | :param request: the request object 213 | :param args: 214 | :param kwargs: 215 | :return: A JSONResponse object with a 200 status code if the request was valid, 216 | or 404 on an invalid request. 217 | """ 218 | 219 | if not self._validate_request(request): 220 | return self.bad_request 221 | 222 | if self._url_validator.is_reserved_url(): 223 | return self._handle_reserved_url_request(request) 224 | 225 | if self._url_validator.is_model_request(): 226 | return self._post_handler(request, self.public_create_endpoints, create=True) 227 | 228 | if self._url_validator.is_model_instance_request(): 229 | return self._post_handler(request, self.public_update_endpoints, create=False) 230 | 231 | 232 | # Currently no support for custom POST requests 233 | return self.bad_request 234 | 235 | def _get_all(self, request): 236 | """ 237 | Handles a request to get all the instances of a model. 238 | Looks for a page number, or defaults to the first page if one isn't found. 239 | 240 | :param request: the request object containing a potential page parameter 241 | :return: Either a list of model instances, or a 404 if the page was invalid, 242 | or no objects exist for the page number provided. 243 | """ 244 | page_number = request.GET.get('page', 1) 245 | 246 | try: 247 | model_dict = self._endpoint_model.get_all(page_number, request.user) 248 | except (EmptyPage, PageNotAnInteger), e: 249 | logger.info(e) 250 | return self.bad_request 251 | 252 | return self.valid_response(model_dict) 253 | 254 | def _get_instance(self, request): 255 | """ 256 | Either retrieves the model instance requested, or upon failure 257 | treats the request as a custom request which is handled by the 258 | model. 259 | :param request: The request object 260 | :return: A dictionary representation of the model instance, 261 | the output of a custom request, or a 404 if both of these failed. 262 | """ 263 | model_instance = self._retrieve_model_instance() 264 | 265 | if model_instance is None: 266 | return self.handle_custom_request(request) 267 | 268 | return self.get_json_response_for_instance(model_instance, request.user) 269 | 270 | def _retrieve_model_instance(self): 271 | """ 272 | Wrapper for retrieving a model instance from the request URL 273 | :return: Either the model instance, or None if the request was invalid 274 | """ 275 | try: 276 | model_instance = self._endpoint_model.get_model_instance(self._url_validator.REQUESTED_MODEL_INSTANCE) 277 | return model_instance 278 | except (ValueError, ObjectDoesNotExist), e: 279 | logger.info(e) 280 | 281 | return None 282 | 283 | def _post_handler(self, request, public_endpoints, create=True): 284 | """ 285 | Handles both create and update POST requests. 286 | 287 | :param request: The request object 288 | :param public_endpoints: A list of endpoints that can be created/updated 289 | (depending on the request type) without user authentication 290 | :param create: Whether or not the request is a CREATE (False == update) 291 | :return: A json representation of the instance created/updated, or 292 | a 404 if the request was bad 293 | """ 294 | if not request.user.is_authenticated() and self._endpoint_model not in public_endpoints: 295 | return self.bad_request 296 | try: 297 | if create: 298 | model_instance = self._endpoint_model.api_create(request) 299 | else: 300 | model_instance = self._retrieve_model_instance() 301 | model_instance = model_instance.api_update(request) 302 | except KeyError, e: 303 | logger.info(e) 304 | return self.bad_request 305 | 306 | if model_instance is None: 307 | return self.bad_request 308 | 309 | return self.get_json_response_for_instance(model_instance, request.user) 310 | 311 | def get_json_response_for_instance(self, model_instance, user): 312 | """ 313 | A wrapper for getting a full json dictionary of a model instance. 314 | 315 | :param model_instance: The instance to dictify. 316 | :param user: The request user object 317 | :return: A json representation of the model instnace 318 | """ 319 | model_instance_dict = model_instance.dictify_with_auth(user, short_dict=False) 320 | return self.valid_response(model_instance_dict) 321 | 322 | def _validate_request(self, request): 323 | """ 324 | Validates an incoming request to ensure if follows a known URL pattern. 325 | Decides what type of request has come in, so that the calling method can 326 | dispatch to the correct handler. 327 | 328 | :param request: the request object 329 | :return: Boolean indicating the validity of the request 330 | """ 331 | self._url_validator = APIUrl(request) 332 | 333 | if not self._url_validator.is_valid_request(): 334 | return False 335 | 336 | if self._url_validator.is_reserved_url(): 337 | return True 338 | 339 | self._endpoint_model = self.registered_endpoints.get(self._url_validator.REQUESTED_MODEL, None) 340 | 341 | if self._endpoint_model is None: 342 | return False 343 | 344 | return True 345 | 346 | @property 347 | def bad_request(self): 348 | """ 349 | Shorthand for returning a 404 (BadJSONResponse) 350 | :return: BadJSONResponse object 351 | """ 352 | return BadJSONResponse(status=StatusCode.NOT_FOUND) 353 | 354 | def valid_response(self, data): 355 | """ 356 | Shorthand for returning a 200 JSON response with some data 357 | :param data: the data to be json-ified 358 | :return: UnsafeJSONResponse object 359 | """ 360 | return UnsafeJSONResponse(data=data) 361 | 362 | def _handle_reserved_url_request(self, request): 363 | """ 364 | Dispatches a (valid) reserved URL request to the correct handler 365 | :param request: the request object 366 | :return: Either the response of the handler, or a 404 367 | if the request wasn't valid 368 | """ 369 | reserved_url = self._url_validator.RESERVED_URL 370 | 371 | if reserved_url == ReservedURL.LOGIN: 372 | return self.handle_login_request(request) 373 | elif reserved_url == ReservedURL.LOGOUT: 374 | return self.handle_logout_request(request) 375 | elif reserved_url == ReservedURL.CSRFTOKEN: 376 | return self.handle_csrf_request(request) 377 | 378 | return self.bad_request 379 | 380 | def handle_login_request(self, request): 381 | """ 382 | Logs in a valid and active user. 383 | :param request: the request object with the user login credentials 384 | :return: A JSON representation of the return_on_login object, 385 | if the login was successful. If it wasn't, a 404 error is returned. 386 | """ 387 | try: 388 | user = authenticate(username=request.POST['username'], password=request.POST['password']) 389 | except KeyError, e: 390 | logger.info(e) 391 | return self.bad_request 392 | 393 | if user is None or not user.is_active: 394 | return self.bad_request 395 | 396 | login(request, user) 397 | return self.get_json_response_for_instance(eval(self.return_on_login), user) 398 | 399 | def handle_logout_request(self, request): 400 | """ 401 | Logs out the user (if logged in) 402 | :param request: the request object 403 | :return: 200 JSON response 404 | """ 405 | logout(request) 406 | return self.valid_response(None) 407 | 408 | def handle_csrf_request(self, request): 409 | """ 410 | Provides an empty 200 response which will always have a CSRF token header 411 | :param request: the request object 412 | :return: 200 JSON response 413 | """ 414 | return self.valid_response(None) 415 | 416 | def handle_custom_request(self, request): 417 | """ 418 | Dispatches a custom request to the endpoint model 419 | :param request: the request object 420 | :return: Either a good response if the endpoint model 421 | successfully handled the custom request, or a 404 if the 422 | request could not be handled 423 | """ 424 | response = self._endpoint_model.api_custom_request(request) 425 | 426 | return self.valid_response(response) if response else self.bad_request -------------------------------------------------------------------------------- /django_api_tools/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'tom' 2 | -------------------------------------------------------------------------------- /django_api_tools/tests/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'szpytfire' 2 | -------------------------------------------------------------------------------- /django_api_tools/tests/conf/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'tom' 2 | -------------------------------------------------------------------------------- /django_api_tools/tests/conf/settings.py: -------------------------------------------------------------------------------- 1 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 2 | import os 3 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 4 | 5 | 6 | # SECURITY WARNING: keep the secret key used in production secret! 7 | SECRET_KEY = 'tc+i_)9dav-rnh(wo!85n)5=kjwq=4p4svp148&!2(41&217)m' 8 | 9 | # SECURITY WARNING: don't run with debug turned on in production! 10 | DEBUG = True 11 | 12 | TEMPLATE_DEBUG = True 13 | 14 | ALLOWED_HOSTS = [] 15 | 16 | 17 | # Application definition 18 | 19 | INSTALLED_APPS = ( 20 | 'django.contrib.admin', 21 | 'django.contrib.auth', 22 | 'django.contrib.contenttypes', 23 | 'django.contrib.sessions', 24 | 'django.contrib.messages', 25 | 'django.contrib.staticfiles', 26 | 'django_api_tools.tests', 27 | ) 28 | 29 | MIDDLEWARE_CLASSES = ( 30 | 'django.contrib.sessions.middleware.SessionMiddleware', 31 | 'django.middleware.common.CommonMiddleware', 32 | 'django.middleware.csrf.CsrfViewMiddleware', 33 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 34 | 'django.contrib.messages.middleware.MessageMiddleware', 35 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 36 | ) 37 | 38 | ROOT_URLCONF = 'django_api_tools.tests.urls' 39 | 40 | WSGI_APPLICATION = 'django_api_tools.tests.conf.wsgi.application' 41 | 42 | 43 | # Database 44 | # https://docs.djangoproject.com/en/1.6/ref/settings/#databases 45 | 46 | DATABASES = { 47 | 'default': { 48 | 'ENGINE': 'django.db.backends.sqlite3', 49 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 50 | } 51 | } 52 | 53 | # Internationalization 54 | # https://docs.djangoproject.com/en/1.6/topics/i18n/ 55 | 56 | LANGUAGE_CODE = 'en-us' 57 | 58 | TIME_ZONE = 'UTC' 59 | 60 | USE_I18N = True 61 | 62 | USE_L10N = True 63 | 64 | USE_TZ = True 65 | 66 | 67 | # Static files (CSS, JavaScript, Images) 68 | # https://docs.djangoproject.com/en/1.6/howto/static-files/ 69 | 70 | STATIC_URL = '/static/' 71 | 72 | FIXTURE_DIRS = ( 73 | os.path.join(BASE_DIR, 'django_api_tools/tests/fixtures/'), 74 | ) -------------------------------------------------------------------------------- /django_api_tools/tests/conf/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | __author__ = 'szpytfire' 6 | 7 | 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_api_tools.tests.conf.settings") 9 | 10 | application = get_wsgi_application() 11 | -------------------------------------------------------------------------------- /django_api_tools/tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'szpytfire' 2 | -------------------------------------------------------------------------------- /django_api_tools/tests/fixtures/bar_baz_qux.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "tests.Bar", 4 | "pk": 1, 5 | "fields": { 6 | "f1": 1, 7 | "baz": 1 8 | } 9 | }, 10 | { 11 | "model": "tests.Bar", 12 | "pk": 2, 13 | "fields": { 14 | "f1": 1, 15 | "baz": 1 16 | } 17 | }, 18 | { 19 | "model": "tests.Bar", 20 | "pk": 3, 21 | "fields": { 22 | "f1": 1, 23 | "baz": 1 24 | } 25 | }, 26 | { 27 | "model": "tests.Bar", 28 | "pk": 4, 29 | "fields": { 30 | "f1": 1, 31 | "baz": 1 32 | } 33 | }, 34 | { 35 | "model": "tests.Baz", 36 | "pk": 1, 37 | "fields": { 38 | "f1": 1 39 | } 40 | }, 41 | { 42 | "model": "tests.Qux", 43 | "pk": 1, 44 | "fields": { 45 | "f1": 1, 46 | "owner": 1 47 | } 48 | } 49 | ] -------------------------------------------------------------------------------- /django_api_tools/tests/fixtures/user_testprofile_foo.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "auth.user", 4 | "pk": 1, 5 | "fields": { 6 | "username": "foo1", 7 | "password": "password1" 8 | } 9 | }, 10 | { 11 | "model": "auth.user", 12 | "pk": 2, 13 | "fields": { 14 | "username": "foo2", 15 | "password": "password1" 16 | } 17 | }, 18 | { 19 | "model": "tests.TestProfile", 20 | "pk": 1, 21 | "fields": { 22 | "user": 1 23 | } 24 | }, 25 | { 26 | "model": "tests.TestProfile", 27 | "pk": 2, 28 | "fields": { 29 | "user": 2 30 | } 31 | }, 32 | { 33 | "model": "tests.Foo", 34 | "pk": 1, 35 | "fields": { 36 | "f1": 1, 37 | "f2": "foo", 38 | "owner": 1 39 | } 40 | }, 41 | { 42 | "model": "tests.Foo", 43 | "pk": 2, 44 | "fields": { 45 | "f1": 1, 46 | "f2": "foo", 47 | "owner": 1 48 | } 49 | }, 50 | { 51 | "model": "tests.Foo", 52 | "pk": 3, 53 | "fields": { 54 | "f1": 1, 55 | "f2": "foo", 56 | "owner": 1 57 | } 58 | }, 59 | { 60 | "model": "tests.Foo", 61 | "pk": 4, 62 | "fields": { 63 | "f1": 1, 64 | "f2": "foo", 65 | "owner": 1 66 | } 67 | }, 68 | { 69 | "model": "tests.Foo", 70 | "pk": 5, 71 | "fields": { 72 | "f1": 1, 73 | "f2": "foo", 74 | "owner": 1 75 | } 76 | }, 77 | { 78 | "model": "tests.Foo", 79 | "pk": 6, 80 | "fields": { 81 | "f1": 1, 82 | "f2": "foo", 83 | "owner": 1 84 | } 85 | }, 86 | { 87 | "model": "tests.Foo", 88 | "pk": 7, 89 | "fields": { 90 | "f1": 1, 91 | "f2": "foo", 92 | "owner": 1 93 | } 94 | }, 95 | { 96 | "model": "tests.Foo", 97 | "pk": 8, 98 | "fields": { 99 | "f1": 1, 100 | "f2": "foo", 101 | "owner": 1 102 | } 103 | }, 104 | { 105 | "model": "tests.Foo", 106 | "pk": 9, 107 | "fields": { 108 | "f1": 1, 109 | "f2": "foo", 110 | "owner": 1 111 | } 112 | }, 113 | { 114 | "model": "tests.Foo", 115 | "pk": 10, 116 | "fields": { 117 | "f1": 1, 118 | "f2": "foo", 119 | "owner": 1 120 | } 121 | }, 122 | { 123 | "model": "tests.Foo", 124 | "pk": 11, 125 | "fields": { 126 | "f1": 1, 127 | "f2": "foo", 128 | "owner": 1, 129 | "active": 0 130 | } 131 | }, 132 | { 133 | "model": "tests.Foo", 134 | "pk": 12, 135 | "fields": { 136 | "f1": 1, 137 | "f2": "foo", 138 | "owner": 1 139 | } 140 | } 141 | ] -------------------------------------------------------------------------------- /django_api_tools/tests/models.py: -------------------------------------------------------------------------------- 1 | from django_api_tools.APIModel import APIModel 2 | 3 | from django.db import models 4 | from django.core.validators import MaxLengthValidator 5 | from django.contrib.auth.models import User 6 | 7 | __author__ = 'szpytfire' 8 | 9 | class TestProfile(APIModel): 10 | user = models.OneToOneField(User, unique=True, related_name='test_profile') 11 | 12 | def is_owner(self, request_user): 13 | return request_user.test_profile == self 14 | 15 | @classmethod 16 | def api_create(cls, request): 17 | return None 18 | 19 | class Foo(APIModel): 20 | owner = models.ForeignKey(TestProfile, related_name='foos') 21 | f1 = models.IntegerField(default=1) 22 | f2 = models.TextField(validators=[MaxLengthValidator(10)]) 23 | 24 | public_fields = ('id',) 25 | registered_user_fields = ('f1', 'onetoone_short_owner') 26 | owner_only_fields = ('f2',) 27 | 28 | 29 | short_description_fields = public_fields 30 | long_description_fields = public_fields + registered_user_fields + owner_only_fields 31 | 32 | def is_owner(self, request_user): 33 | return request_user.test_profile == self.owner 34 | 35 | @classmethod 36 | def api_create(cls, request): 37 | foo = Foo.objects.create(owner=request.user.test_profile, f2=request.POST['f2']) 38 | return foo 39 | 40 | def api_update(self, request): 41 | if not self.is_owner(request.user): 42 | return None 43 | 44 | if request.POST.get('f1'): 45 | self.f1 += 1 46 | 47 | return super(Foo, self).api_update(request) 48 | 49 | class BarBaz(APIModel): 50 | f1 = models.IntegerField(default=1) 51 | public_fields = ('id',) 52 | registered_user_fields = () 53 | 54 | short_description_fields = public_fields 55 | long_description_fields = public_fields + registered_user_fields 56 | 57 | def is_owner(self, request_user): 58 | return True 59 | 60 | @classmethod 61 | def api_create(cls, request): 62 | bar = Bar.objects.create() 63 | return bar 64 | 65 | def api_update(self, request): 66 | if not self.is_owner(request.user): 67 | return None 68 | 69 | if request.POST.get('f1'): 70 | self.f1 += 1 71 | 72 | return super(BarBaz, self).api_update(request) 73 | 74 | class Meta: 75 | abstract = True 76 | 77 | class Bar(BarBaz): 78 | baz = models.ForeignKey('Baz', related_name='bars') 79 | registered_user_fields = ('f1', 'fk_short_baz') 80 | 81 | class Baz(BarBaz): 82 | registered_user_fields = ('f1', 'rel_short_bars') 83 | 84 | class Qux(BarBaz): 85 | owner = models.ForeignKey(Baz, related_name='quxs') 86 | foos = models.ManyToManyField(Foo) 87 | 88 | @classmethod 89 | def api_custom_request(cls, request): 90 | return "yo!" -------------------------------------------------------------------------------- /django_api_tools/tests/tests.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django_api_tools.APIModel import APIModel, UserAuthCode 4 | from django_api_tools.APIView import APIUrl, ReservedURL, StatusCode 5 | from django_api_tools.tests.models import Foo, Bar, Baz, Qux, TestProfile 6 | from django_api_tools.tests.views import TestAPIView 7 | 8 | from django.test import TestCase 9 | from django.test.client import RequestFactory, Client 10 | from django.contrib.auth.models import AnonymousUser, User 11 | from django.core.paginator import EmptyPage, PageNotAnInteger 12 | from django.core.exceptions import ObjectDoesNotExist 13 | 14 | __author__ = 'szpytfire' 15 | 16 | 17 | class APIToolsTestCase(TestCase): 18 | 19 | def assertDictKeysEqual(self, dict, keys): 20 | # For related fields, APIModel cuts off the special related syntax when dictifying 21 | # We should therefore do the same when testing for the correct keys 22 | 23 | for index, val in enumerate(keys): 24 | prefix = filter(lambda prefix: val.startswith(prefix), APIModel._reserved_prefixes) 25 | if prefix: 26 | keys[index] = keys[index][len(prefix[0]) + 1:] 27 | 28 | self.assertSetEqual(set(dict.keys()), set(keys)) 29 | 30 | class APIModelTestCase(APIToolsTestCase): 31 | 32 | fixtures = ['user_testprofile_foo.json', 'bar_baz_qux.json'] 33 | 34 | def remove_foreign_key_fields(self, fields): 35 | return [field for field in fields if not filter(lambda prefix: field.startswith(prefix), APIModel._reserved_prefixes)] 36 | 37 | def test_dictify(self): 38 | foo = Foo.objects.get(id=1) 39 | foo._curr_user = AnonymousUser() 40 | # Test no fields to include returns empty dict 41 | self.assertDictEqual(foo.dictify([], False), {}) 42 | 43 | # Test random fields to include returns empty dict 44 | self.assertDictEqual(foo.dictify(['bar1', 'bar2'], False), {}) 45 | 46 | # Test defaults to public user 47 | self.assertDictKeysEqual(foo.dictify(Foo.long_description_fields, False), list(Foo.public_fields)) 48 | 49 | # Test correct registered user fields returned 50 | foo._user_auth = UserAuthCode.REGISTERED_USER 51 | self.assertDictKeysEqual(foo.dictify(Foo.long_description_fields, False), list(Foo.public_fields + Foo.registered_user_fields)) 52 | 53 | # Test correct owner fields returned 54 | foo._user_auth = UserAuthCode.OWNER 55 | self.assertDictKeysEqual(foo.dictify(Foo.long_description_fields, False), list(Foo.public_fields + Foo.registered_user_fields + Foo.owner_only_fields)) 56 | 57 | def test_dictify_helper(self): 58 | user = User.objects.get(id=1) 59 | 60 | foo = Foo.objects.get(id=1) 61 | foo.set_user_auth(user) 62 | # Test no dictified fields returned for empty fields to return 63 | self.assertDictEqual(foo.dictify_helper(Foo.public_fields, [], False), {}) 64 | 65 | # Test no dictified fields returned for fields which aren't in the auth level 66 | self.assertDictEqual(foo.dictify_helper(Foo.public_fields, ['bar1', 'bar2'], False), {}) 67 | 68 | # Test regular field is set in the dictionary 69 | dictified_foo = foo.dictify_helper(Foo.public_fields, Foo.public_fields, False) 70 | self.assertEqual(dictified_foo['id'], foo.id) 71 | 72 | # Test invalid regular fields is set as None 73 | non_existent_field = ('test', ) 74 | dictified_foo = foo.dictify_helper(non_existent_field, non_existent_field, False) 75 | self.assertIsNone(dictified_foo[non_existent_field[0]]) 76 | 77 | # Test invalid related field is set as None 78 | non_existent_rel_field = ('fk_short_test', ) 79 | dictified_foo = foo.dictify_helper(non_existent_rel_field, non_existent_rel_field, False) 80 | self.assertIsNone(dictified_foo['test']) 81 | 82 | # Test fk_short only returns the foreign model's ID 83 | fk_short_field = ('fk_short_baz', ) 84 | bar = Bar.objects.get(id=1) 85 | bar.set_user_auth(user) 86 | dictified_bar = bar.dictify_helper(fk_short_field, fk_short_field, False) 87 | self.assertEqual(len(dictified_bar), 1) 88 | self.assertDictKeysEqual(dictified_bar['baz'], self.remove_foreign_key_fields(bar.baz.short_description_fields)) 89 | 90 | # Test fk_long returns the foreign model's dictify_long() 91 | fk_long_field = ('fk_long_baz', ) 92 | dictified_bar = bar.dictify_helper(fk_long_field, fk_long_field, False) 93 | self.assertEqual(len(dictified_bar), 1) 94 | self.assertDictKeysEqual(dictified_bar['baz'], self.remove_foreign_key_fields(bar.baz.short_description_fields + bar.baz.long_description_fields)) 95 | 96 | # Test onetoone_short only returns the foreign model's ID 97 | onetoone_short_field = ('onetoone_short_owner', ) 98 | dictified_foo = foo.dictify_helper(onetoone_short_field, onetoone_short_field, False) 99 | self.assertEqual(len(dictified_foo), 1) 100 | self.assertDictKeysEqual(dictified_foo['owner'], self.remove_foreign_key_fields(foo.owner.short_description_fields)) 101 | 102 | # Test onetoone_long returns the foreign model's dictify_long() 103 | fk_long_field = ('onetoone_long_owner', ) 104 | qux = Qux.objects.get(id=1) 105 | qux.set_user_auth(user) 106 | dictified_qux = qux.dictify_helper(fk_long_field, fk_long_field, False) 107 | self.assertEqual(len(dictified_qux), 1) 108 | self.assertDictKeysEqual(dictified_qux['owner'], self.remove_foreign_key_fields(qux.owner.short_description_fields + qux.owner.long_description_fields)) 109 | 110 | # Test rel_short only returns the related models' ID's 111 | rel_short_field = ('rel_short_bars', ) 112 | baz = Baz.objects.get(id=1) 113 | baz.set_user_auth(user) 114 | dictified_baz = baz.dictify_helper(rel_short_field, rel_short_field, False) 115 | self.assertEqual(len(dictified_baz), 1) 116 | self.assertEqual(len(dictified_baz['bars']), baz.bars.all().count()) 117 | self.assertDictKeysEqual(dictified_baz['bars'][0], self.remove_foreign_key_fields(baz.bars.all()[0].short_description_fields)) 118 | 119 | # Test rel_long returns the related models' dictify_long() 120 | rel_long_field = ('rel_long_bars', ) 121 | dictified_baz = baz.dictify_helper(rel_long_field, rel_long_field, False) 122 | self.assertEqual(len(dictified_baz), 1) 123 | self.assertEqual(len(dictified_baz['bars']), baz.bars.all().count()) 124 | self.assertDictKeysEqual(dictified_baz['bars'][0], self.remove_foreign_key_fields(baz.bars.all()[0].short_description_fields + baz.bars.all()[0].long_description_fields)) 125 | 126 | # Test m2m_short only returns the related models' ID's 127 | m2m_short_field = ('m2m_short_foos', ) 128 | qux = Qux.objects.get(id=1) 129 | qux.set_user_auth(user) 130 | qux.foos.add(foo) 131 | 132 | dictified_qux = qux.dictify_helper(m2m_short_field, m2m_short_field, False) 133 | self.assertEqual(len(dictified_qux), 1) 134 | self.assertEqual(len(dictified_qux['foos']), qux.foos.all().count()) 135 | self.assertDictKeysEqual(dictified_qux['foos'][0], self.remove_foreign_key_fields(qux.foos.all()[0].short_description_fields)) 136 | 137 | # Test m2m_long returns the related models' dictify_long() 138 | m2m_long_field = ('m2m_long_foos', ) 139 | dictified_qux = qux.dictify_helper(m2m_long_field, m2m_long_field, False) 140 | self.assertEqual(len(dictified_qux), 1) 141 | self.assertEqual(len(dictified_qux['foos']), qux.foos.all().count()) 142 | self.assertDictKeysEqual(dictified_qux['foos'][0], self.remove_foreign_key_fields(qux.foos.all()[0].short_description_fields + qux.foos.all()[0].long_description_fields)) 143 | 144 | def test_dictify_short(self): 145 | # Test that the method only returns the short description fields 146 | foo = Foo.objects.get(id=1) 147 | self.assertDictKeysEqual(foo.dictify_short(False), Foo.short_description_fields) 148 | 149 | def test_dictify_long(self): 150 | # Test that the method returns the long and short description fields 151 | foo = Foo.objects.get(id=1) 152 | owner = TestProfile.objects.get(id=1).user 153 | foo.set_user_auth(owner) 154 | self.assertDictKeysEqual(foo.dictify_long(False), list(Foo.short_description_fields + Foo.long_description_fields)) 155 | 156 | def test_dictify_with_auth(self): 157 | active_foo = Foo.objects.get(id=1) 158 | deactivated_foo = Foo.objects.filter(active=0)[0] 159 | 160 | owner = User.objects.get(id=1) 161 | not_owner = User.objects.get(id=2) 162 | public_user = AnonymousUser() 163 | 164 | # Test whether a deactivated instance returns None 165 | self.assertIsNone(deactivated_foo.dictify_with_auth(owner, False)) 166 | 167 | # Test whether a public user only sees the public fields 168 | self.assertDictKeysEqual(active_foo.dictify_with_auth(public_user, False), list(Foo.public_fields)) 169 | 170 | # Test whether an owner can view all the fields 171 | self.assertDictKeysEqual(active_foo.dictify_with_auth(owner, False), list(Foo.public_fields + Foo.registered_user_fields + Foo.owner_only_fields)) 172 | 173 | # Test whether a registered user sees registered user + public fields 174 | self.assertDictKeysEqual(active_foo.dictify_with_auth(not_owner, False), list(Foo.public_fields + Foo.registered_user_fields)) 175 | 176 | def test_is_owner(self): 177 | # Test ownership of Foo 178 | foo = Foo.objects.get(id=1) 179 | 180 | # Test Foo with its rightful owner 181 | # Test Foo with its rightful owner 182 | owner = User.objects.get(id=1) 183 | self.assertTrue(foo.is_owner(owner)) 184 | 185 | # Test Foo with an incorrect owner 186 | not_owner = User.objects.get(id=2) 187 | self.assertFalse(foo.is_owner(not_owner)) 188 | 189 | # Test Bar with an arbitrary user - Bar's don't have an owner. 190 | bar = Bar.objects.get(id=1) 191 | self.assertTrue(bar.is_owner(owner)) 192 | 193 | def test_get_all(self): 194 | user = User.objects.get(id=1) 195 | # Test number of Foo's equal to 10 196 | self.assertEqual(len(Foo.get_all(1, user)), Foo.pagination) 197 | 198 | # Test number of Bar's equal to number of Bar's (< 10) 199 | self.assertEqual(len(Bar.get_all(1, user)), Bar.objects.all().count()) 200 | 201 | # Test invalid page number raises expected exception 202 | with self.assertRaises(EmptyPage): 203 | Bar.get_all(2, user) 204 | 205 | # Test invalid page value raises expected exception 206 | with self.assertRaises(PageNotAnInteger): 207 | Bar.get_all("foo", user) 208 | 209 | def test_get_model_instance(self): 210 | # Test getting a Foo object with a valid ID 211 | valid_foo_id = 1 212 | 213 | # Make sure the method returns the right object 214 | foo = Foo.objects.get(id=valid_foo_id) 215 | self.assertEqual(Foo.get_model_instance(valid_foo_id), foo) 216 | 217 | # Test invalid lookup raises expected exception 218 | with self.assertRaises(ValueError): 219 | Foo.objects.get(id="foo") 220 | 221 | with self.assertRaises(ObjectDoesNotExist): 222 | Foo.objects.get(id=20) 223 | 224 | class APIViewTestCase(APIToolsTestCase): 225 | 226 | fixtures = ['user_testprofile_foo.json', 'bar_baz_qux.json'] 227 | urls = 'django_api_tools.tests.urls' 228 | 229 | def setUp(self): 230 | self.factory = RequestFactory() 231 | 232 | def test_get(self): 233 | t = TestAPIView() 234 | # Test invalid request gives back 404 235 | request = self.factory.get('/test_api/') 236 | response = t.get(request) 237 | self.assertEqual(response.status_code, StatusCode.NOT_FOUND) 238 | 239 | # Test reserved URL gives back 200 240 | request = self.factory.get('/test_api/{}'.format(ReservedURL.CSRFTOKEN)) 241 | response = t.get(request) 242 | self.assertEqual(response.status_code, StatusCode.OK) 243 | 244 | user = User.objects.get(id=1) 245 | 246 | # Test model request returns 200 247 | request = self.factory.get('/test_api/foo/') 248 | request.user = user 249 | response = t.get(request) 250 | self.assertEqual(response.status_code, StatusCode.OK) 251 | 252 | # Test get instance gives back 200 253 | request = self.factory.get('/test_api/foo/1/') 254 | request.user = user 255 | response = t.get(request) 256 | self.assertEqual(response.status_code, StatusCode.OK) 257 | 258 | # Test custom request on model with custom_request implemented gives back 200 259 | request = self.factory.get('/test_api/qux/1/custom/') 260 | request.user = user 261 | response = t.get(request) 262 | self.assertEqual(response.status_code, StatusCode.OK) 263 | 264 | # Test custom request on model without implementation gives back 404 265 | request = self.factory.get('/test_api/foo/1/custom/') 266 | request.user = user 267 | response = t.get(request) 268 | self.assertEqual(response.status_code, StatusCode.NOT_FOUND) 269 | 270 | def test_post(self): 271 | t = TestAPIView() 272 | 273 | # Test invalid request gives back 404 274 | request = self.factory.post('/test_api/') 275 | response = t.post(request) 276 | self.assertEqual(response.status_code, StatusCode.NOT_FOUND) 277 | 278 | # Test reserved URL gives back 200 279 | request = self.factory.post('/test_api/{}/'.format(ReservedURL.CSRFTOKEN)) 280 | response = t.post(request) 281 | self.assertEqual(response.status_code, StatusCode.OK) 282 | 283 | user = User.objects.get(id=1) 284 | 285 | # Test post model request (create) returns 200 286 | APIUrl.ADDITIONAL_FIELDS = list() 287 | request = self.factory.post('/test_api/foo/', data={"f2": "foo"}) 288 | request.user = user 289 | response = t.post(request) 290 | self.assertEqual(response.status_code, StatusCode.OK) 291 | 292 | # Test post instance (update) gives back 200 293 | APIUrl.ADDITIONAL_FIELDS = list() 294 | foo = Foo.objects.get(id=1) 295 | request = self.factory.post('/test_api/foo/{}/'.format(foo.id), data={"f1": True}) 296 | request.user = user 297 | response = t.post(request) 298 | self.assertEqual(response.status_code, StatusCode.OK) 299 | 300 | def test_get_all(self): 301 | user = User.objects.get(id=1) 302 | t = TestAPIView() 303 | 304 | # Test get first page of Foo's gives back 10 results 305 | request = self.factory.get('/test_api/foo/') 306 | request.user = user 307 | t._endpoint_model = Foo 308 | response = t._get_all(request) 309 | self.assertEqual(len(json.loads(response.content)), 10) 310 | 311 | # Test second page of Foo's gives back 1 results 312 | request = self.factory.get('/test_api/foo/', data={"page": 2}) 313 | request.user = user 314 | t._endpoint_model = Foo 315 | response = t._get_all(request) 316 | self.assertEqual(len(json.loads(response.content)), 1) 317 | 318 | # Test third page of Foo's gives back 404 319 | request = self.factory.get('/test_api/foo/', data={"page": 3}) 320 | request.user = user 321 | t._endpoint_model = Foo 322 | response = t._get_all(request) 323 | self.assertIsNone(json.loads(response.content)) 324 | 325 | def test_get_instance(self): 326 | user = User.objects.get(id=1) 327 | t = TestAPIView() 328 | 329 | # Test Foo ID = 1 gives back 200/ correct Foo 330 | foo = Foo.objects.get(id=1) 331 | foo_dict = foo.dictify_with_auth(user, short_dict=False) 332 | request = self.factory.get('/test_api/foo/{}/'.format(foo.id)) 333 | request.user = user 334 | t._endpoint_model = Foo 335 | t._url_validator = APIUrl(request) 336 | response = t._get_instance(request) 337 | self.assertDictEqual(json.loads(response.content), foo_dict) 338 | self.assertEqual(response.status_code, StatusCode.OK) 339 | 340 | # Test Foo ID = 22 gives back 404/ none 341 | request = self.factory.get('/test_api/foo/22/') 342 | request.user = user 343 | t._endpoint_model = Foo 344 | t._url_validator = APIUrl(request) 345 | response = t._get_instance(request) 346 | self.assertEqual(response.status_code, StatusCode.NOT_FOUND) 347 | self.assertIsNone(json.loads(response.content)) 348 | 349 | # Test Foo ID = "foo" gives back 404 350 | request = self.factory.get('/test_api/foo/foo/') 351 | request.user = user 352 | t._endpoint_model = Foo 353 | t._url_validator = APIUrl(request) 354 | response = t._get_instance(request) 355 | self.assertEqual(response.status_code, StatusCode.NOT_FOUND) 356 | self.assertIsNone(json.loads(response.content)) 357 | 358 | # Test Qux /custom/ gives back 200/ correct value 359 | request = self.factory.get('/test_api/qux/custom/') 360 | request.user = user 361 | t._endpoint_model = Qux 362 | t._url_validator = APIUrl(request) 363 | response = t._get_instance(request) 364 | self.assertEqual(response.status_code, StatusCode.OK) 365 | self.assertEqual(json.loads(response.content), Qux.api_custom_request(request)) 366 | 367 | def test_post_handler(self): 368 | t = TestAPIView() 369 | 370 | # Test non-authenticated user and private endpoint gives back 404 371 | request = self.factory.post('/test_api/qux/') 372 | request.user = AnonymousUser() 373 | public_endpoints = (Foo, ) 374 | t._endpoint_model = Qux 375 | response = t._post_handler(request, public_endpoints) 376 | self.assertEqual(response.status_code, StatusCode.NOT_FOUND) 377 | 378 | # Test create: 379 | f2_val = "hello" 380 | user = User.objects.get(id=1) 381 | request = self.factory.post('/test_api/foo/', data={"f2": f2_val}) 382 | request.user = user 383 | public_endpoints = (Qux, ) 384 | t._endpoint_model = Foo 385 | response = t._post_handler(request, public_endpoints) 386 | foo_dict = json.loads(response.content) 387 | self.assertEqual(response.status_code, StatusCode.OK) 388 | self.assertEqual(foo_dict['f2'], f2_val) 389 | self.assertEqual(foo_dict, Foo.objects.get(id=foo_dict['id']).dictify_with_auth(user, short_dict=False)) 390 | 391 | # Test create Foo with bad/missing fields returns 404 392 | f1_val = "hello" 393 | request = self.factory.post('/test_api/foo/', data={"f1": f1_val}) 394 | request.user = user 395 | response = t._post_handler(request, public_endpoints) 396 | self.assertEqual(response.status_code, StatusCode.NOT_FOUND) 397 | 398 | # Test update with owner returns 200 + updated foo object 399 | foo = Foo.objects.get(id=1) 400 | f1_before = foo.f1 401 | foo1_url = '/test_api/foo/{}/'.format(foo.id) 402 | request = self.factory.post(foo1_url, data={"f1": True}) 403 | request.user = user 404 | t._url_validator = APIUrl(request) 405 | response = t._post_handler(request, public_endpoints, create=False) 406 | self.assertEqual(response.status_code, StatusCode.OK) 407 | response_content = json.loads(response.content) 408 | self.assertEqual(response_content['f1'], f1_before + 1) 409 | new_foo = Foo.objects.get(id=1) 410 | self.assertDictEqual(new_foo.dictify_with_auth(user, False), response_content) 411 | 412 | # Test update with non owner returns 404 413 | request = self.factory.post(foo1_url, data={"f1": True}) 414 | request.user = AnonymousUser() 415 | response = t._post_handler(request, public_endpoints, create=False) 416 | self.assertEqual(response.status_code, StatusCode.NOT_FOUND) 417 | 418 | # Test deactivate gives back 404 + Test that the deactivate date is set 419 | request = self.factory.post(foo1_url, data={"deactivate": True}) 420 | request.user = user 421 | response = t._post_handler(request, public_endpoints, create=False) 422 | self.assertEqual(response.status_code, StatusCode.NOT_FOUND) 423 | 424 | def test_get_json_response_for_instance(self): 425 | foo = Foo.objects.get(id=1) 426 | t = TestAPIView() 427 | 428 | # Test Anonymous user gives back public fields 429 | user = AnonymousUser() 430 | response_content = t.get_json_response_for_instance(foo, user).content 431 | self.assertDictKeysEqual(json.loads(response_content), Foo.public_fields) 432 | 433 | # Test registered user gives back all fields 434 | user = User.objects.get(id=2) 435 | response_content = t.get_json_response_for_instance(foo, user).content 436 | self.assertDictKeysEqual(json.loads(response_content), list(Foo.public_fields + Foo.registered_user_fields)) 437 | 438 | # Test owner gives back all fields 439 | user = User.objects.get(id=1) 440 | response_content = t.get_json_response_for_instance(foo, user).content 441 | self.assertDictKeysEqual(json.loads(response_content), list(Foo.public_fields + Foo.registered_user_fields + Foo.owner_only_fields)) 442 | 443 | 444 | def test_validate_request(self): 445 | t = TestAPIView() 446 | 447 | # Test invalid request returns False 448 | request = self.factory.get('/test_api/fob/') 449 | self.assertFalse(t._validate_request(request)) 450 | 451 | request = self.factory.get('/test_api/123/123/123/') 452 | self.assertFalse(t._validate_request(request)) 453 | 454 | # Test valid request returns True 455 | request = self.factory.get('/test_api/foo/') 456 | self.assertTrue(t._validate_request(request)) 457 | 458 | # Test reserved URL returns True 459 | request = self.factory.get('/test_api/{}/'.format(ReservedURL.LOGIN)) 460 | self.assertTrue(t._validate_request(request)) 461 | 462 | def test_handle_login_logout_request(self): 463 | # We need to use Django's Client to test the login 464 | # as RequestFactory doesn't offer any middleware by default 465 | c = Client() 466 | login_url = "/test_api/{}/".format(ReservedURL.LOGIN) 467 | # Test valid user login returns the user's profile + sets cookies 468 | valid_user = User.objects.get(id=1) 469 | new_password = "newpassword1" 470 | valid_user.set_password(new_password) 471 | valid_user.save() 472 | response = c.post(login_url, data={"username": valid_user.username, "password": new_password}) 473 | self.assertEqual(response.status_code, StatusCode.OK) 474 | self.assertDictEqual(json.loads(response.content), valid_user.test_profile.dictify_with_auth(valid_user, short_dict=False)) 475 | 476 | # Test that logout deletes the authenticated session 477 | session_val_before = response.cookies['sessionid'].value 478 | response = c.post("/test_api/{}/".format(ReservedURL.LOGOUT)) 479 | session_val_after = response.cookies['sessionid'].value 480 | self.assertNotEqual(session_val_before, session_val_after) 481 | 482 | # Test an invalid login returns 404 483 | response = c.post(login_url, data={"username": valid_user.username, "password": "badpassword"}) 484 | self.assertEqual(response.status_code, StatusCode.NOT_FOUND) 485 | 486 | # Test inactive user login returns 404 487 | valid_user.is_active = False 488 | valid_user.save() 489 | response = c.post(login_url, data={"username": valid_user.username, "password": new_password}) 490 | self.assertEqual(response.status_code, StatusCode.NOT_FOUND) 491 | 492 | def test_handle_csrf_request(self): 493 | # Test csrf request sets a token 494 | c = Client() 495 | response = c.get("/test_api/{}".format(ReservedURL.CSRFTOKEN)) 496 | self.assertIsNotNone(response.cookies['csrftoken'].value) 497 | 498 | def test_handle_custom_request(self): 499 | t = TestAPIView() 500 | 501 | # Test model which handles custom request returns 200 502 | request = self.factory.get('/test_api/qux/custom/') 503 | t._endpoint_model = Qux 504 | response = t.handle_custom_request(request) 505 | self.assertEqual(response.status_code, StatusCode.OK) 506 | 507 | # Test model which doesn't handle custom request returns 404 508 | request = self.factory.get('/test_api/foo/custom/') 509 | t._endpoint_model = Foo 510 | response = t.handle_custom_request(request) 511 | self.assertEqual(response.status_code, StatusCode.NOT_FOUND) 512 | 513 | class APIUrlTestCase(APIToolsTestCase): 514 | 515 | def setUp(self): 516 | self.factory = RequestFactory() 517 | 518 | def test_split_url_components(self): 519 | # Test an invalid request 520 | request = self.factory.get("/api/") 521 | splitter = APIUrl(request) 522 | self.assertFalse(splitter.is_valid_request()) 523 | 524 | # Test a model request 525 | MODEL_NAME = "foo" 526 | request = self.factory.get("/api/{}/".format(MODEL_NAME)) 527 | splitter = APIUrl(request) 528 | self.assertTrue(splitter.is_valid_request()) 529 | self.assertTrue(splitter.is_model_request()) 530 | self.assertEqual(MODEL_NAME, splitter.REQUESTED_MODEL) 531 | 532 | # Test a model instance request 533 | MODEL_INSTANCE = "1" 534 | request = self.factory.get("/api/{}/{}/".format(MODEL_NAME, MODEL_INSTANCE)) 535 | splitter = APIUrl(request) 536 | self.assertTrue(splitter.is_valid_request()) 537 | self.assertTrue(splitter.is_model_instance_request()) 538 | self.assertEqual(MODEL_NAME, splitter.REQUESTED_MODEL) 539 | self.assertEqual(MODEL_INSTANCE, splitter.REQUESTED_MODEL_INSTANCE) 540 | 541 | # Test a reserved URL request 542 | reserved_url = ReservedURL.LOGOUT 543 | request = self.factory.get("/api/{}/".format(reserved_url)) 544 | splitter = APIUrl(request) 545 | self.assertTrue(splitter.is_valid_request()) 546 | self.assertTrue(splitter.is_reserved_url()) 547 | self.assertEqual(reserved_url, splitter.RESERVED_URL) 548 | 549 | # Test a custom request 550 | reserved_url = ReservedURL.LOGOUT 551 | request = self.factory.get("/api/{}/".format(reserved_url)) 552 | splitter = APIUrl(request) 553 | self.assertTrue(splitter.is_valid_request()) 554 | self.assertTrue(splitter.is_reserved_url()) 555 | self.assertEqual(reserved_url, splitter.RESERVED_URL) -------------------------------------------------------------------------------- /django_api_tools/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, include, url 2 | from django.views.decorators.csrf import ensure_csrf_cookie 3 | from django_api_tools.tests.views import TestAPIView 4 | 5 | from django.contrib import admin 6 | 7 | __author__ = 'szpytfire' 8 | 9 | admin.autodiscover() 10 | 11 | urlpatterns = patterns('', 12 | url(r'^test_api/', ensure_csrf_cookie(TestAPIView.as_view())), 13 | ) -------------------------------------------------------------------------------- /django_api_tools/tests/views.py: -------------------------------------------------------------------------------- 1 | from django_api_tools.APIView import APIView 2 | from django_api_tools.tests.models import Foo, Qux, TestProfile 3 | 4 | __author__ = 'szpytfire' 5 | 6 | 7 | class TestAPIView(APIView): 8 | registered_endpoints = { 9 | 'profile': TestProfile, 10 | 'foo': Foo, 11 | 'qux': Qux 12 | } 13 | 14 | public_create_endpoints = (TestProfile, ) 15 | 16 | return_on_login = 'user.test_profile' -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | __author__ = 'szpytfire' 5 | 6 | 7 | if __name__ == "__main__": 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_api_tools.tests.conf.settings") 9 | 10 | from django.core.management import execute_from_command_line 11 | 12 | execute_from_command_line(sys.argv) -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | __author__ = 'szpytfire' 4 | 5 | setup( 6 | name = 'django_api_tools', 7 | packages = ['django_api_tools'], 8 | version = '0.1.1', 9 | description = 'Django API add-on is a mini-framework which allows developers to run RESTful APIs alongside websites using Forms/Templates.', 10 | author = 'Tom Szpytman', 11 | author_email = 'mail@tomszpytman.com', 12 | url = 'https://github.com/szpytfire/django-api-tools', 13 | download_url = 'https://github.com/szpytfire/django-api-tools/tarball/0.1.1', 14 | keywords = ['django', 'api', 'rest'], 15 | classifiers = [], 16 | ) 17 | --------------------------------------------------------------------------------