diff --git a/.gitignore b/.gitignore index 62d7a88..2e47d73 100644 --- a/.gitignore +++ b/.gitignore @@ -57,5 +57,6 @@ docs/_build/ target/ local.ini +test.ini mock/ mock.sh diff --git a/example.raml b/example.raml index 62b8905..a3e802c 100644 --- a/example.raml +++ b/example.raml @@ -5,7 +5,7 @@ documentation: - title: Home content: | Welcome to the example API. -baseUri: http://{host}:{port}/{version} +baseUri: http://{host}:{port}/api version: v1 mediaType: application/json protocols: [HTTP, HTTPS] @@ -65,8 +65,34 @@ securedBy: [x_ticket_auth] body: application/json: schema: !include schemas/user.json + # uppercase used in email to test use of lowercase processor + example: | + { + "username": "rick", + "email": "RICK@example.com", + "password": "megatrees", + "first_name": "Rick" + } + responses: + 201: + description: Created user + body: + application/json: + schema: !include schemas/user.json + headers: + Location: + description: The URL where the created user is available + type: string + pattern: "http.*" + example: http://localhost:6543/api/users/rick + patch: description: Update multiple users + body: + application/json: + example: | + { "last_name": "Sanchez" } + head: description: Determine whether a given resource is available options: @@ -74,11 +100,59 @@ securedBy: [x_ticket_auth] /{username}: displayName: One user + + uriParameters: + username: + type: string + maxLength: 50 + example: rick + get: description: Get a particular user - patch: + responses: + 200: + body: + application/json: + schema: !include schemas/user.json + put: + description: Replace a particular user + body: + application/json: + example: | + { + "username": "morty", + "email": "morty@example.com", + "first_name": "Mortimer", + "last_name": "Smith", + "password": "$2a$10$RrAZgBWzCXaBR.uM83AOg.YzYfnhxujau7JuQ2enP1ota3lgyt/9S", + "status": "active", + "profile": null, + "groups": ["user"], + "settings": {}, + "stories": [], + "assigned_stories": [], + "last_login": null, + "created_at": "2015-09-11T02:13:29Z", + "updated_at": "2015-09-11T03:48:55Z" + } + responses: + 200: + body: + application/json: + schema: !include schemas/user.json + + patch: description: Update a particular user + body: + application/json: + example: { "username": "rickC137" } + responses: + 200: + body: + application/json: + schema: !include schemas/user.json + delete: description: Delete a particular user @@ -88,6 +162,9 @@ securedBy: [x_ticket_auth] description: Get all settings of a particular user post: description: Change a user's settings + body: + application/json: + example: { "language": "en" } /groups: displayName: User groups @@ -95,6 +172,9 @@ securedBy: [x_ticket_auth] description: Get all groups of a particular user post: description: Change a user's groups + body: + application/json: + example: { "admin": null } /profile: securedBy: [user_profile_acl] @@ -106,21 +186,57 @@ securedBy: [x_ticket_auth] body: application/json: schema: !include schemas/profile.json + example: { "address": "123 Fake St" } + responses: + 200: + body: + application/json: + schema: !include schemas/profile.json patch: description: Update a user's profile + body: + application/json: + example: { "address": "124 Pretend Rd" } + responses: + 200: + body: + application/json: + schema: !include schemas/profile.json /stories: securedBy: [item_owner_acl] displayName: All stories + get: description: Get all stories + post: description: Create a new story body: application/json: schema: !include schemas/story.json + example: | + { + "id": 1, + "owner_id": "rick", + "due_date": "2020-11-11T11:11:11Z", + "name": "do science", + "description": "real sciency stuff" + } + responses: + 201: + description: Created story + body: + application/json: + + schema: !include schemas/story.json + patch: description: Update multiple stories + body: + application/json: + example: { "assignee_id": "rick" } + delete: description: Delete multiple stories head: @@ -130,13 +246,70 @@ securedBy: [x_ticket_auth] /{id}: displayName: One story + + uriParameters: + id: + description: story ID + type: integer + minimum: 1 + example: 1 + get: description: Get a particular story + responses: + 200: + body: + application/json: + schema: !include schemas/story.json + + put: + description: Replace a particular story + body: + application/json: + example: | + { + "owner_id": "rick", + "due_date": "2020-11-11T11:11:11Z", + "name": "watch TV", + "description": "not very sciency", + "assignee_id": "rick", + "arbitrary_object": null, + "attachment": null, + "available_for": null, + "completed": false, + "created_at": "2015-09-11T05:01:27Z", + "id": 516, + "price": null, + "progress": 0.0, + "rating": null, + "reads": 0, + "signs_number": null, + "start_date": null, + "unicode_description": null, + "unicode_name": null, + "updated_at": null, + "valid_date": null, + "valid_time": null + } + responses: + 200: + body: + application/json: + schemas: !include schemas/story.json + + patch: + description: Update a story + body: + application/json: + example: { "completed": true } + responses: + 200: + body: + application/json: + schemas: !include schemas/story.json + delete: description: Delete a particular story - patch: - put: - description: Update a particular story head: description: Determine whether a given resource is available options: diff --git a/local.ini.template b/local.ini.template index 9fc2ac0..fd5f13f 100644 --- a/local.ini.template +++ b/local.ini.template @@ -15,6 +15,11 @@ system.user = system system.password = 123456 system.email = user@domain.com +# Database configuration: +# +# Note on testing: copy this template to test.ini and use a different database +# and elasticsearch index, as they will be wiped on each test run. + # SQLA sqlalchemy.url = postgresql://localhost:5432/ramses_example diff --git a/requirements.dev b/requirements.dev index 4b9c22f..e4e4c80 100644 --- a/requirements.dev +++ b/requirements.dev @@ -6,4 +6,5 @@ waitress==0.8.9 -e ../nefertari-mongodb -e ../nefertari-sqla -e ../ramses +-e ../ra -e . diff --git a/requirements.txt b/requirements.txt index f5791f5..ee6116c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,5 +6,6 @@ nefertari==0.6.1 nefertari-mongodb==0.4.1 nefertari-sqla==0.4.1 ramses==0.5.1 +ra -e . diff --git a/schemas/user.json b/schemas/user.json index c87d8d0..8fd7256 100644 --- a/schemas/user.json +++ b/schemas/user.json @@ -26,7 +26,7 @@ } }, "profile": { - "type": ["integer", "string"], + "type": ["integer", "string", "null"], "_db_settings": { "type": "relationship", "document": "Profile", @@ -160,4 +160,4 @@ } } } -} \ No newline at end of file +} diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..0b93740 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,161 @@ +import ra +import pytest + + +# ra entry point: instantiate the API test suite +api = ra.api('example.raml') + + +@pytest.fixture(scope='session') +def models(): + from nefertari import engine + return dict( + User=engine.get_document_cls('User'), + Profile=engine.get_document_cls('Profile'), + Story=engine.get_document_cls('Story')) + + +@pytest.fixture(scope='module', autouse=True) +def drop_storages(request): + def _drop_es(): + from nefertari.elasticsearch import ES + ES.delete_index() + request.addfinalizer(_drop_es) + + +# perform any necessary test setup +@pytest.fixture(autouse=True) +def setup(req, examples, models): + User = models['User'] + Story = models['Story'] + Profile = models['Profile'] + + import transaction + import time + + def delete_data(): + Profile._delete_many(Profile.get_collection()) + Story._delete_many(Story.get_collection()) + User._delete_many(User.get_collection()) + transaction.commit() + + def create_user(): + example = examples.build('user') + user = User(**example).save() + # XXX: it takes some time for the object to be propagated to ES. + # This is not ideal at all. + time.sleep(2) + return user + + def create_profile(user): + example = examples.build('user.profile', user=user) + Profile(**example).save() + time.sleep(2) + + def create_story(): + example = examples.build('story') + Story(**example).save() + + delete_data() + + if req.match(exclude='POST /users'): + user = create_user() + + if req.match('PATCH /users/{username}/profile'): + create_profile(user) + + if req.match('/stories*', exclude='POST'): + create_story() + + import transaction + transaction.commit() + + +# defining a resource scope: + +@api.resource('/users') +def users_resource(users): + + # scope-local pytest fixtures + # + # a resource scope acts just like a regular module scope + # with respect to pytest fixtures: + + @pytest.fixture + def two_hundred(): + return 200 + + # defining tests for methods in this resource: + + @users.get + def get(req, two_hundred): + # ``req`` is a callable request object that is pre-bound to the app + # that was passed into ``ra.api`` as well as the URI derived from + # the resource (test scope) and method (test) decorators. + # + # This example uses the other scope-local fixture defined above. + response = req() + assert response.status_code == two_hundred + assert 'rick' in response + + @users.post + def post_using_example(req): + # By default, when JSON data needs to be sent in the request body, + # Ra will look for an ``example`` property in the RAML definition + # of the resource method's body and use that. + # + # As in WebTest request methods, you can specify the expected + # status code(s), which will be test the response status. + response = req(status=201) + # assert lowercase validator in schema is working: + assert response.json['email'] == 'rick@example.com' + + # defining a custom user factory; underscored functions are not + # considered tests (but better to import factories from another module) + def _user_factory(): + import string + import random + name = ''.join(random.choice(string.ascii_lowercase) + for _ in range(10)) + email = "{}@example.com".format(name) + return dict(username=name, email=email, password=name) + + # using the factory: + + @users.post(factory=_user_factory) + def post_using_factory(req): + response = req() + username = req.data['username'] + assert username in response + + # defining a sub-resource: + + @users.resource('/{username}') + def user_resource(user): + + # this resource will be requested at /users/{username} + # + # By default, Ra will look at the ``example`` property for + # URI parameters as defined in the RAML, and fill the URI + # template with that. In this case it will use 'rick', which + # we created in the before-hook. + + @user.get + def get(req): + # This is equivalent to the default test for a resource + # and method: + req() + + +@api.resource('/stories') +def stories_resource(stories): + + @stories.resource('/{id}') + def story_resource(story): + + @story.get + def get(req): + response = req() + assert 'do science' in response + +api.autotest() diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..3bfaec7 --- /dev/null +++ b/tox.ini @@ -0,0 +1,22 @@ +[tox] +envlist = + py27, + py33,py34, + +[testenv] +setenv = + PYTHONHASHSEED=0 +deps = -rrequirements.dev +commands = py.test + +[testenv:flake8] +deps = + flake8==2.3.0 + pep8==1.6.2 +commands = + flake8 ra + +[pytest] +addopts = -x -v +norecursedirs = env .tox +testpaths = tests