Skip to content

Commit

Permalink
Documentation and test framework improved
Browse files Browse the repository at this point in the history
Related to #1 and #2
Also includes a fix on the paginator for Django REST framework
  • Loading branch information
Dani Carrion committed Sep 11, 2016
1 parent 566ce76 commit a8183d8
Show file tree
Hide file tree
Showing 8 changed files with 320 additions and 34 deletions.
228 changes: 227 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,228 @@
# pyrestcli
Generic, object-oriented Python client for REST APIs

*Generic, object-oriented Python client for REST APIs*

_pyrestcli_ allows you to define data models, with a syntax that is derived from Django's model framework, that you can use directly against REST APIs. All the internals of the communication with the API is transparently handled by _pyrestcli_ for you.

## Installation

```
$ pip install pyrestcli
```

## Usage

### API base URL and authentication

First, you need to define how to reach and be authorized to use the API. _pyrestcli_ includes out-of-the-box support for no authentication, HTTP basic authentication and HTTP-header-based token authentication. This is an example for HTTP basic authentication on http://test.com/api:

```python
from pyrestcli.auth import BasicAuthClient

auth_client = BasicAuthClient("admin", "admin", "http://test.com/api")
```

# Basic model definition and operations

Now, you need to create your models, according to the schema of the data available on the server.

For instance, let us take a REST object that represents a person like this:

```json
{
id: 1,
name: "John Doe",
email: "[email protected]"
}
```

The corresponding model in _pyrestcli_ would be:

```python
from pyrestcli.fields import CharField, IntegerField
from pyrestcli.resources import Resource, Manager

class Person(Resource):
id = IntegerField()
name = CharField()
email = CharField()

class PersonManager(Manager):
resource_class = Person
```

Also, `BooleanField` and `DateTimeField` are available.

Now we could very easily get the list of persons found at http://test.com/api/persons, assuming the list returned by the server looks like:

```json
{
data: [
{
id: 1,
name: "Jane Doe",
email: "[email protected]"
},
{
id: 2,
name: "John Doe",
email: "[email protected]"
}
]
}
```

For this, we would simply do:

```python
person_manager = PersonManager()
persons = PersonManager.all()
```

Now ```persons``` is an array of 2 ```Person``` objects. You can also get one single object like this:

```python
jane_doe = person_manager.get(1)
```

Similarly, you can also get a filtered list of persons, if supported by the API:

```python
persons = PersonManager.filter(name="John Doe")
```

That would be translated into a request like this: http://test.com/api/persons/?name=John%20Doe

Pagination is supported. A paginator compatible with Django REST Framework is provided, but it should be pretty straightforward to subclass the main `Paginator` object and adapt it for each particular case:

```python
from pyrestcli.paginators import NextWithUrlPaginator


class PersonManager(Manager):
resource_class = Person
paginator_class = NextWithUrlPaginator
```

When defining the models, it's also possible to use another field as the _id_ of the model, another name for the endpoint, or another name for the JSON attribute that holds the collection, instead of the default `data`:

```python
class Person(Resource):
id = IntegerField()
name = CharField()
email = CharField()

class Meta:
id_field = "email"
collection_endpoint = "people"

class PersonManager(Manager):
resource_class = Person
json_collection_attribute = "results"
```

Our `jane_doe` object can be updated easily:

```python
jane_doe.email = "[email protected]"
jone_doe.save()
```

Or deleted:

```python
jone_doe.delete()
```

Creating another person is also straightforward:

```python
jimmy_doe = person_manager.create(name="Jimmy Doe", email="[email protected]")
```

### Custom fields and resources

Let us assume there is another API model for cars, where `owner` is linked to a person.

```json
{
id: 1,
make: "Toyota",
owner: 1
}
```

We could create another _pyrestcli_ model such as this:

```python
from pyrestcli.fields import CharField, IntegerField, ResourceField
from pyrestcli.resources import Resource, Manager


class PersonField(ResourceField):
value_class = "Person"


class Car(Resource):
id = IntegerField()
make = CharField()
owner = PersonField()

class Meta:
name_field = "make"

class CarManager(Manager):
resource_class = Car
```

Because `Car` does not have a `name` field, we need to specify which field to be used to get a friendly representation of the model.

This works as expected, and the `owner` attribute of a `Car` object is a `Person` object. One caveat is, if the API does not give the full `Person` object when getting a `Car` object, but only its id instead (quite usual), you will have to call the `refresh` method on the `Person` object to have it populated.

### What's next?

Full documentation is yet to be written, but code is reasonably well commented and the test suite includes a basic, yet complete example of how to use _pyrestcli_.

## Test suite

_pyrestcli_ includes a test suite on the `tests`. The test suite is not available if you install _pyrestcli_ with _pip_. Rather, you need to download _pyrestcli_ directly from GitHub and install it locally.

First, clone the repo:

```
$ git clone [email protected]:danicarrion/pyrestcli.git
```

Cd into the folder, create and enable the virtualenv and install _pyrestcli_:

```
$ cd pyrestcli
$ virtualenv env
$ source env/bin/activate
$ pip install -e .
```

The test suite is run against a [Django](https://www.djangoproject.com/) server that uses [Django Rest Framework](http://www.django-rest-framework.org/) to serve some test models over a REST API. In order to install these, you need to:

```
$ cd tests
$ pip install -r requirements.txt
```

Now, you need to migrate the database and start the server:

```
$ cd restserver
$ python manage.py migrate
$ python manage.py runserver
```

The test app creates two models `Question` and `Choice` exactly as defined by the [Django tutorial](https://docs.djangoproject.com/en/1.10/intro/tutorial01/), together with their corresponding REST serializers. Also, a user is created to test authentication. User name is "admin" and password is "admin" too. You can use that user to take a look at how the REST API looks like on the Web browsable API located at http://localhost:8000/.

In order to run the tests, you need to go back to the main folder, on a terminal with the virtualenv activated, and do:

```
$ py.test tests
```

Any time, you can clean up the test database by deleting the database file and running the migrations again.
9 changes: 8 additions & 1 deletion pyrestcli/paginators.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
class Paginator:
def __init__(self, base_url):
self.base_url = base_url

def get_urls(self, initial_url):
raise NotImplemented

Expand All @@ -24,6 +27,10 @@ def get_urls(self, initial_url):
yield self.url

def process_response(self, response):
self.url = response["next"] if "next" in response else None
response_json = response.json()
try:
self.url = response_json["next"].replace(self.base_url, "")
except AttributeError:
self.url = None

return response
2 changes: 1 addition & 1 deletion pyrestcli/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ def __init__(self, auth_client):
:param auth_client: Client to make (non)authorized requests
:return:
"""
self.paginator = self.paginator_class()
self.paginator = self.paginator_class(auth_client.base_url)
super(Manager, self).__init__(auth_client)

@classmethod
Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ def basic_auth_client():
Returns a basic HTTP authentication client that can be used to send authenticated test requests to the server
:return: BasicAuthClient instance
"""
return BasicAuthClient("admin", "password123", "http://localhost:8000")
return BasicAuthClient("admin", "admin", "http://localhost:8000")
3 changes: 3 additions & 0 deletions tests/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from pyrestcli.fields import CharField, IntegerField, DateTimeField, ResourceField
from pyrestcli.resources import Resource, Manager
from pyrestcli.paginators import NextWithUrlPaginator


class QuestionField(ResourceField):
Expand Down Expand Up @@ -33,8 +34,10 @@ class Meta:
class QuestionManager(Manager):
resource_class = Question
json_collection_attribute = "results"
paginator_class = NextWithUrlPaginator


class ChoiceManager(Manager):
resource_class = Choice
json_collection_attribute = "results"
paginator_class = NextWithUrlPaginator
42 changes: 42 additions & 0 deletions tests/restserver/polls/migrations/0002_auto_20160911_1457.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from __future__ import unicode_literals

from django.db import migrations
from django.contrib.auth.hashers import make_password
from django.utils import timezone


def create_test_models(apps, schema_editor):
User = apps.get_model("auth", "User")
Question = apps.get_model("polls", "Question")
Choice = apps.get_model("polls", "Choice")

user = User(username="admin", email="[email protected]")
user.password = make_password('admin')
user.save()

question_1 = Question(question_text="Do you like pizza?", pub_date=timezone.now())
question_1.save()

choice_1_1 = Choice(question=question_1, choice_text="Yes", votes=5)
choice_1_1.save()
choice_1_2 = Choice(question=question_1, choice_text="No", votes=2)
choice_1_2.save()

question_2 = Question(question_text="Do you like spaguetti?", pub_date=timezone.now())
question_2.save()

choice_2_1 = Choice(question=question_2, choice_text="Yes", votes=7)
choice_2_1.save()
choice_2_2 = Choice(question=question_2, choice_text="Yes", votes=4)
choice_2_2.save()


class Migration(migrations.Migration):

dependencies = [
('polls', '0001_initial'),
]

operations = [
migrations.RunPython(create_test_models),
]
21 changes: 4 additions & 17 deletions tests/restserver/restserver/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@
# Application definition

INSTALLED_APPS = [
'polls.apps.PollsConfig',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'polls.apps.PollsConfig',
]

MIDDLEWARE = [
Expand Down Expand Up @@ -86,20 +86,7 @@
# Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
AUTH_PASSWORD_VALIDATORS = []


# Internationalization
Expand All @@ -123,10 +110,10 @@


REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAdminUser',),
'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',),
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.BasicAuthentication',
),
'PAGE_SIZE': 10
'PAGE_SIZE': 2
}
Loading

0 comments on commit a8183d8

Please sign in to comment.