├── VERSION ├── tests ├── test_utils │ ├── __init__.py │ ├── test_data.py │ └── test_utils.py ├── test_authentication │ ├── __init__.py │ ├── fixtures.py │ └── test_policies.py ├── test_pyramid_integration.py ├── test_engine.py ├── test_acl.py ├── test_json_httpexceptions.py ├── test_polymorphic.py ├── test_view_helpers.py └── test_events.py ├── nefertari ├── scripts │ ├── __init__.py │ ├── post2api.py │ ├── scaffold_test.py │ └── es.py ├── scaffolds │ ├── nefertari_starter │ │ ├── +package+ │ │ │ ├── tests │ │ │ │ ├── __init__.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── items.json │ │ │ │ ├── test_api.py_tmpl │ │ │ │ └── api.raml │ │ │ ├── views │ │ │ │ ├── __init__.py │ │ │ │ └── items.py_tmpl │ │ │ ├── models │ │ │ │ ├── __init__.py_tmpl │ │ │ │ └── item.py_tmpl │ │ │ └── __init__.py_tmpl │ │ ├── requirements.txt │ │ ├── MANIFEST.in_tmpl │ │ ├── README.md │ │ ├── setup.py_tmpl │ │ └── local.ini_tmpl │ └── __init__.py ├── utils │ ├── __init__.py │ ├── data.py │ ├── dictset.py │ └── utils.py ├── authentication │ ├── __init__.py │ ├── policies.py │ ├── views.py │ └── models.py ├── logstash.py ├── __init__.py ├── engine.py ├── acl.py ├── json_httpexceptions.py ├── tweens.py ├── renderers.py ├── polymorphic.py └── view_helpers.py ├── MANIFEST.in ├── docs ├── source │ ├── nefertari.jpg │ ├── index.rst │ ├── getting_started.rst │ ├── development_tools.rst │ ├── views.rst │ ├── why.rst │ ├── auth.rst │ ├── field_processors.rst │ ├── models.rst │ ├── making_requests.rst │ ├── changelog.rst │ └── event_handlers.rst └── Makefile ├── requirements.dev ├── setup.cfg ├── tox.ini ├── .travis.yml ├── CONTRIBUTING.md ├── README.md ├── .gitignore └── setup.py /VERSION: -------------------------------------------------------------------------------- 1 | 0.7.0 -------------------------------------------------------------------------------- /tests/test_utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nefertari/scripts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_authentication/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nefertari/scaffolds/nefertari_starter/+package+/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nefertari/scaffolds/nefertari_starter/+package+/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include VERSION 3 | recursive-include nefertari/scaffolds * -------------------------------------------------------------------------------- /docs/source/nefertari.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramses-tech/nefertari/HEAD/docs/source/nefertari.jpg -------------------------------------------------------------------------------- /requirements.dev: -------------------------------------------------------------------------------- 1 | mock 2 | pytest 3 | pytest-cov 4 | releases 5 | sphinx 6 | sphinxcontrib-fulltoc 7 | webtest 8 | virtualenv 9 | 10 | -e . 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | match=^test 3 | nocapture=1 4 | cover-package=nefertari 5 | with-coverage=1 6 | cover-erase=1 7 | verbosity = 3 8 | with-id = 1 9 | -------------------------------------------------------------------------------- /nefertari/scaffolds/nefertari_starter/requirements.txt: -------------------------------------------------------------------------------- 1 | nefertari 2 | 3 | cryptacular==1.4.1 4 | Paste==2.0.2 5 | pyramid==1.6.1 6 | waitress==0.8.9 7 | 8 | -e . 9 | -------------------------------------------------------------------------------- /nefertari/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from nefertari.utils.data import * 2 | from nefertari.utils.dictset import * 3 | from nefertari.utils.utils import * 4 | 5 | _split = split_strip 6 | -------------------------------------------------------------------------------- /nefertari/scaffolds/nefertari_starter/MANIFEST.in_tmpl: -------------------------------------------------------------------------------- 1 | include *.txt *.ini *.cfg *.rst 2 | recursive-include {{package}} *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml 3 | -------------------------------------------------------------------------------- /nefertari/scaffolds/nefertari_starter/README.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | ``` 3 | $ pip install -r requirements.txt 4 | ``` 5 | 6 | ## Run 7 | ``` 8 | $ pserve local.ini 9 | ``` 10 | -------------------------------------------------------------------------------- /nefertari/scaffolds/nefertari_starter/+package+/tests/requirements.txt: -------------------------------------------------------------------------------- 1 | -e git+https://github.com/ramses-tech/ra.git@develop#egg=ra 2 | -e git+https://github.com/ramses-tech/nefertari-mongodb.git@develop#egg=nefertari-mongodb 3 | -------------------------------------------------------------------------------- /nefertari/scaffolds/nefertari_starter/+package+/models/__init__.py_tmpl: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | __all__ = ('Item',) 4 | 5 | this = sys.modules[__name__] 6 | 7 | 8 | def includeme(config): 9 | pass 10 | 11 | 12 | from {{package}}.models.item import Item 13 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py27, 4 | py33,py34,py35, 5 | 6 | [testenv] 7 | setenv = 8 | PYTHONHASHSEED=0 9 | deps = -rrequirements.dev 10 | commands = 11 | py.test {posargs:--cov nefertari tests} 12 | python nefertari/scripts/scaffold_test.py -s nefertari_starter 13 | 14 | [testenv:flake8] 15 | deps = 16 | flake8 17 | commands = 18 | flake8 nefertari 19 | -------------------------------------------------------------------------------- /nefertari/scaffolds/nefertari_starter/+package+/models/item.py_tmpl: -------------------------------------------------------------------------------- 1 | from nefertari import engine as eng 2 | from nefertari.engine import ESBaseDocument 3 | 4 | 5 | class Item(ESBaseDocument): 6 | __tablename__ = 'items' 7 | 8 | _public_fields = ['id', 'name', 'description'] 9 | 10 | id = eng.IdField(primary_key=True) 11 | name = eng.StringField(required=True) 12 | description = eng.TextField() 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.org 2 | language: python 3 | env: 4 | - TOXENV=py27 5 | - TOXENV=py33 6 | - TOXENV=py34 7 | - TOXENV=py35 8 | python: 3.5 9 | install: 10 | - pip install tox 11 | script: tox 12 | services: 13 | - elasticsearch 14 | - mongodb 15 | before_script: 16 | - travis_retry curl -XDELETE 'http://localhost:9200/nefertari_starter/' 17 | - mongo nefertari_starter --eval 'db.dropDatabase();' 18 | -------------------------------------------------------------------------------- /tests/test_authentication/fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture(scope='module') 5 | def engine_mock(request): 6 | import nefertari 7 | from mock import Mock 8 | 9 | original_engine = nefertari.engine 10 | nefertari.engine = Mock() 11 | nefertari.engine.BaseDocument = object 12 | 13 | def clear(): 14 | nefertari.engine = original_engine 15 | request.addfinalizer(clear) 16 | 17 | return nefertari.engine 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Team members 2 | 3 | In alphabetical order: 4 | 5 | * [Amos Latteier](https://github.com/latteier) 6 | * [Artem Kostiuk](https://github.com/postatum) 7 | * [Chris Hart](https://github.com/chrstphrhrt) 8 | * [Jonathan Stoikovitch](https://github.com/jstoiko) 9 | 10 | ## Pull-requests 11 | 12 | Pull-requests are welcomed! 13 | 14 | ## Testing 15 | 16 | 1. Install dev requirements by running `pip install -r requirements.dev` 17 | 2. Run tests using `py.test --cov nefertari tests` 18 | -------------------------------------------------------------------------------- /nefertari/authentication/__init__.py: -------------------------------------------------------------------------------- 1 | def includeme(config): 2 | """ Set up event subscribers. """ 3 | from .models import ( 4 | AuthUserMixin, 5 | random_uuid, 6 | lower_strip, 7 | encrypt_password, 8 | ) 9 | add_proc = config.add_field_processors 10 | add_proc( 11 | [random_uuid, lower_strip], 12 | model=AuthUserMixin, field='username') 13 | add_proc([lower_strip], model=AuthUserMixin, field='email') 14 | add_proc([encrypt_password], model=AuthUserMixin, field='password') 15 | -------------------------------------------------------------------------------- /tests/test_pyramid_integration.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import mock 3 | 4 | 5 | class TestPyramidIntegration(unittest.TestCase): 6 | 7 | def test_includeme(self): 8 | from nefertari import includeme 9 | 10 | config = mock.Mock() 11 | config.registry.settings = {'auth': True} 12 | includeme(config) 13 | 14 | self.assertEqual(3, config.add_directive.call_count) 15 | self.assertEqual(2, config.add_renderer.call_count) 16 | root = config.get_root_resource() 17 | assert root.auth 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `Nefertari` 2 | [![Build Status](https://travis-ci.org/ramses-tech/nefertari.svg?branch=master)](https://travis-ci.org/ramses-tech/nefertari) 3 | [![Documentation](https://readthedocs.org/projects/nefertari/badge/?version=stable)](http://nefertari.readthedocs.org) 4 | 5 | Nefertari is a REST API framework sitting on top of [Pyramid](https://github.com/Pylons/pyramid) and [Elasticsearch](https://www.elastic.co/downloads/elasticsearch). She currently offers two backend engines: [SQLA](https://github.com/ramses-tech/nefertari-sqla) and [MongoDB](https://github.com/ramses-tech/nefertari-mongodb). 6 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Nefertari, queen of APIs 2 | ======================== 3 | 4 | Nefertari is a REST API framework for Pyramid that uses Elasticsearch for reads and either MongoDB or Postgres for writes. 5 | 6 | Nefertari is fully production ready and actively maintained. 7 | 8 | Source code: 9 | ``_ 10 | 11 | 12 | Table of Content 13 | ================ 14 | 15 | .. toctree:: 16 | :maxdepth: 2 17 | 18 | getting_started 19 | views 20 | models 21 | auth 22 | event_handlers 23 | field_processors 24 | making_requests 25 | development_tools 26 | why 27 | changelog 28 | 29 | 30 | .. image:: nefertari.jpg 31 | 32 | Image credit: Wikipedia 33 | -------------------------------------------------------------------------------- /nefertari/scaffolds/nefertari_starter/+package+/tests/items.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "title": "Item schema", 4 | "$schema": "http://json-schema.org/draft-04/schema", 5 | "required": ["name"], 6 | "properties": { 7 | "id": { 8 | "type": ["string", "null"], 9 | "_db_settings": { 10 | "type": "id_field", 11 | "required": true, 12 | "primary_key": true 13 | } 14 | }, 15 | "name": { 16 | "type": "string", 17 | "_db_settings": { 18 | "type": "string", 19 | "required": true 20 | } 21 | }, 22 | "description": { 23 | "type": ["string", "null"], 24 | "_db_settings": { 25 | "type": "text" 26 | } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /docs/source/getting_started.rst: -------------------------------------------------------------------------------- 1 | Getting started 2 | =============== 3 | 4 | Create your project in a virtualenv directory (see the `virtualenv documentation `_) 5 | 6 | .. code-block:: shell 7 | 8 | $ virtualenv my_project 9 | $ source my_project/bin/activate 10 | $ pip install nefertari 11 | $ pcreate -s nefertari_starter my_project 12 | $ cd my_project 13 | $ pserve local.ini 14 | 15 | 16 | Requirements 17 | ------------ 18 | 19 | * Python 2.7, 3.3 or 3.4 20 | * Elasticsearch for Elasticsearch-powered resources (see :any:`models ` and :any:`requests `) 21 | * Postgres or Mongodb or Your Data Store™ 22 | 23 | 24 | Tutorials 25 | --------- 26 | 27 | - For a more complete example of a Pyramid project using Nefertari, you can take a look at the `Example Project `_. 28 | -------------------------------------------------------------------------------- /nefertari/scaffolds/nefertari_starter/setup.py_tmpl: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | requires = [] 4 | 5 | setup( 6 | name='{{package}}', 7 | version='0.0.1', 8 | description='', 9 | long_description='', 10 | classifiers=[ 11 | "Programming Language :: Python", 12 | "Framework :: Pyramid", 13 | "Topic :: Internet :: WWW/HTTP", 14 | "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", 15 | ], 16 | author='', 17 | author_email='', 18 | url='', 19 | keywords='web pyramid pylons rest api nefertari', 20 | packages=find_packages(), 21 | include_package_data=True, 22 | zip_safe=False, 23 | install_requires=requires, 24 | tests_require=requires, 25 | test_suite="{{package}}", 26 | entry_points="""\ 27 | [paste.app_factory] 28 | main = {{package}}:main 29 | """, 30 | ) 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .python-version 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | 7 | # C extensions 8 | *.so 9 | 10 | .DS_Store 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | venv* 60 | -------------------------------------------------------------------------------- /nefertari/scaffolds/nefertari_starter/+package+/tests/test_api.py_tmpl: -------------------------------------------------------------------------------- 1 | import os 2 | import ra 3 | import webtest 4 | import pytest 5 | 6 | appdir = os.path.abspath( 7 | os.path.join(os.path.dirname(__file__), '..', '..')) 8 | ramlfile = os.path.abspath( 9 | os.path.join(os.path.dirname(__file__), 'api.raml')) 10 | testapp = webtest.TestApp('config:local.ini', relative_to=appdir) 11 | 12 | 13 | @pytest.fixture(autouse=True) 14 | def setup(req, examples): 15 | """ Setup database state for tests. 16 | 17 | NOTE: For objects to be created, transaction needs to be commited 18 | as follows: 19 | import transaction 20 | transaction.commit() 21 | """ 22 | from nefertari import engine 23 | Item = engine.get_document_cls('Item') 24 | 25 | if req.match(exclude='POST /items'): 26 | if Item.get_collection(_count=True) == 0: 27 | example = examples.build('item') 28 | Item(**example).save() 29 | 30 | 31 | # ra entry point: instantiate the API test suite 32 | api = ra.api(ramlfile, testapp) 33 | api.autotest() 34 | -------------------------------------------------------------------------------- /nefertari/scaffolds/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | from six import moves 5 | from pyramid.scaffolds import PyramidTemplate 6 | 7 | 8 | class NefertariStarterTemplate(PyramidTemplate): 9 | _template_dir = 'nefertari_starter' 10 | summary = 'Nefertari starter' 11 | 12 | def pre(self, command, output_dir, vars): 13 | dbengine_choices = {'1': 'sqla', '2': 'mongodb'} 14 | vars['engine'] = dbengine_choices[moves.input(""" 15 | Which database backend would you like to use: 16 | 17 | (1) for SQLAlchemy/PostgreSQL, or 18 | (2) for MongoEngine/MongoDB? 19 | 20 | [default is '1']: """) or '1'] 21 | 22 | if vars['package'] == 'site': 23 | raise ValueError(""" 24 | "Site" is a reserved keyword in Python. 25 | Please use a different project name. """) 26 | 27 | def post(self, command, output_dir, vars): 28 | os.chdir(str(output_dir)) 29 | subprocess.call('pip install -r requirements.txt', shell=True) 30 | subprocess.call('pip install nefertari-{}'.format(vars['engine']), 31 | shell=True) 32 | msg = """Goodbye boilerplate! Welcome to Nefertari.""" 33 | self.out(msg) 34 | -------------------------------------------------------------------------------- /docs/source/development_tools.rst: -------------------------------------------------------------------------------- 1 | Development Tools 2 | ================= 3 | 4 | Indexing in Elasticsearch 5 | ------------------------- 6 | 7 | ``nefertari.index`` console script can be used to manually (re-)index models from your database engine to Elasticsearch. 8 | 9 | You can run it like so:: 10 | 11 | $ nefertari.index --config local.ini --models Model 12 | 13 | The available options are: 14 | 15 | --config specify ini file to use (required) 16 | --models list of models to index. Models must subclass ESBaseDocument. 17 | --params URL-encoded parameters for each module 18 | --quiet "quiet mode" (surpress output) 19 | --index Specify name of index. E.g. the slug at the end of http://localhost:9200/example_api 20 | --chunk Index chunk size 21 | --force Force re-indexation of all documents in database engine (defaults to False) 22 | 23 | Importing bulk data 24 | ------------------- 25 | 26 | ``nefertari.post2api`` console script can be used to POST data to your api. It may be useful to import data in bulk, e.g. mock data. 27 | 28 | You can run it like so:: 29 | 30 | $ nefertari.post2api -f ./users.json -u http://localhost:6543/api/users 31 | 32 | The available options are: 33 | 34 | -f specify a json file containing an array of json objects 35 | -u specify the url of the collection you wish to POST to 36 | -------------------------------------------------------------------------------- /nefertari/scaffolds/nefertari_starter/+package+/views/items.py_tmpl: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from nefertari.view import BaseView 4 | 5 | from {{package}}.models import Item 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | class ItemsView(BaseView): 11 | Model = Item 12 | 13 | def index(self): 14 | return self.get_collection_es() 15 | 16 | def show(self, **kwargs): 17 | return self.Model.get_item( 18 | id=kwargs.pop('item_id'), **kwargs) 19 | 20 | def create(self): 21 | item = self.Model(**self._json_params) 22 | return item.save(self.request) 23 | 24 | def update(self, **kwargs): 25 | item = self.Model.get_item( 26 | id=kwargs.pop('item_id'), **kwargs) 27 | return item.update(self._json_params, self.request) 28 | 29 | def replace(self, **kwargs): 30 | return self.update(**kwargs) 31 | 32 | def delete(self, **kwargs): 33 | item = self.Model.get_item( 34 | id=kwargs.pop('item_id'), **kwargs) 35 | item.delete(self.request) 36 | 37 | def delete_many(self): 38 | es_items = self.get_collection_es() 39 | items = self.Model.filter_objects(es_items) 40 | return self.Model._delete_many(items, self.request) 41 | 42 | def update_many(self): 43 | es_items = self.get_collection_es() 44 | items = self.Model.filter_objects(es_items) 45 | 46 | return self.Model._update_many( 47 | items, self._json_params, self.request) 48 | -------------------------------------------------------------------------------- /nefertari/logstash.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import logging 3 | import logstash 4 | 5 | from nefertari.utils import dictset 6 | log = logging.getLogger(__name__) 7 | 8 | 9 | def includeme(config): 10 | log.info('Including logstash') 11 | Settings = dictset(config.registry.settings) 12 | 13 | try: 14 | if not Settings.asbool('logstash.enable'): 15 | log.warning('Logstash is disabled') 16 | return 17 | 18 | if Settings.asbool('logstash.check'): 19 | import socket 20 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 21 | deftimeout = sock.gettimeout() 22 | sock.settimeout(3) 23 | try: 24 | sock.sendto( 25 | 'PING', 0, 26 | (Settings['logstash.host'], 27 | Settings.asint('logstash.port'))) 28 | recv, svr = sock.recvfrom(255) 29 | sock.shutdown(2) 30 | except Exception as e: 31 | log.error('Looks like logstash server is not running: %s' % e) 32 | finally: 33 | sock.settimeout(deftimeout) 34 | 35 | logger = logging.getLogger() 36 | handler = logstash.LogstashHandler( 37 | Settings['logstash.host'], 38 | Settings.asint('logstash.port'), 39 | version=1) 40 | handler.setFormatter(logging.Formatter( 41 | "%(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] " 42 | "%(module)s.%(funcName)s: %(message)s")) 43 | logger.addHandler(handler) 44 | 45 | except KeyError as e: 46 | log.warning('Bad settings for logstash. %s' % e) 47 | -------------------------------------------------------------------------------- /nefertari/scaffolds/nefertari_starter/local.ini_tmpl: -------------------------------------------------------------------------------- 1 | [app:{{package}}] 2 | use = egg:{{package}} 3 | 4 | # Nefertari 5 | nefertari.engine = nefertari_{{engine}} 6 | enable_get_tunneling = true 7 | 8 | # SQLA 9 | sqlalchemy.url = postgresql://%(host)s:5432/{{package}} 10 | 11 | # MongoDB 12 | mongodb.host = localhost 13 | mongodb.port = 27017 14 | mongodb.db = {{package}} 15 | 16 | # Elasticsearch 17 | elasticsearch.hosts = localhost:9200 18 | elasticsearch.sniff = false 19 | elasticsearch.index_name = {{package}} 20 | elasticsearch.chunk_size = 1000 21 | elasticsearch.enable_refresh_query = false 22 | elasticsearch.enable_aggregations = false 23 | elasticsearch.enable_polymorphic_query = false 24 | 25 | # {{package}} 26 | host = localhost 27 | base_url = http://%(host)s:6543 28 | 29 | # CORS 30 | cors.enable = false 31 | cors.allow_origins = %(base_url)s 32 | cors.allow_credentials = true 33 | 34 | ### 35 | # wsgi server configuration 36 | ### 37 | 38 | [composite:main] 39 | use = egg:Paste#urlmap 40 | /api/ = {{package}} 41 | 42 | [server:main] 43 | use = egg:waitress#main 44 | host = localhost 45 | port = 6543 46 | threads = 3 47 | 48 | [loggers] 49 | keys = root, {{package}}, nefertari 50 | 51 | [handlers] 52 | keys = console 53 | 54 | [formatters] 55 | keys = generic 56 | 57 | [logger_root] 58 | level = INFO 59 | handlers = console 60 | 61 | [logger_{{package}}] 62 | level = INFO 63 | handlers = 64 | qualname = {{package}} 65 | 66 | [logger_nefertari] 67 | level = INFO 68 | handlers = 69 | qualname = nefertari 70 | 71 | [handler_console] 72 | class = StreamHandler 73 | args = (sys.stderr,) 74 | level = NOTSET 75 | formatter = generic 76 | 77 | [formatter_generic] 78 | format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(module)s.%(funcName)s: %(message)s 79 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import setup, find_packages 4 | 5 | here = os.path.abspath(os.path.dirname(__file__)) 6 | README = open(os.path.join(here, 'README.md')).read() 7 | VERSION = open(os.path.join(here, 'VERSION')).read() 8 | 9 | install_requires = [ 10 | 'pyramid', 11 | 'tempita', 12 | 'requests', 13 | 'simplejson', 14 | 'elasticsearch', 15 | 'blinker', 16 | 'zope.dottedname', 17 | 'cryptacular', 18 | 'six', 19 | ] 20 | 21 | setup( 22 | name='nefertari', 23 | version=VERSION, 24 | description='REST API framework for Pyramid', 25 | long_description=README, 26 | classifiers=[ 27 | "Programming Language :: Python", 28 | "Programming Language :: Python :: 2", 29 | "Programming Language :: Python :: 2.7", 30 | "Programming Language :: Python :: 3", 31 | "Programming Language :: Python :: 3.4", 32 | "Programming Language :: Python :: 3.5", 33 | "Framework :: Pyramid", 34 | "Topic :: Internet :: WWW/HTTP", 35 | "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", 36 | ], 37 | author='Ramses Tech', 38 | author_email='hello@ramses.tech', 39 | url='https://github.com/ramses-tech/nefertari', 40 | keywords='web wsgi bfg pylons pyramid rest api elasticsearch', 41 | packages=find_packages(), 42 | include_package_data=True, 43 | zip_safe=False, 44 | test_suite='nefertari', 45 | install_requires=install_requires, 46 | entry_points="""\ 47 | [console_scripts] 48 | nefertari.index = nefertari.scripts.es:main 49 | nefertari.post2api = nefertari.scripts.post2api:main 50 | [pyramid.scaffold] 51 | nefertari_starter = nefertari.scaffolds:NefertariStarterTemplate 52 | """, 53 | ) 54 | -------------------------------------------------------------------------------- /tests/test_engine.py: -------------------------------------------------------------------------------- 1 | from mock import Mock, patch 2 | 3 | 4 | class TestEngine(object): 5 | @patch('nefertari.engine.resolve') 6 | def test_includeme(self, mock_resolve): 7 | module = Mock() 8 | config = Mock() 9 | config.registry.settings = {'nefertari.engine': 'foo'} 10 | module.log = 1 11 | module.__testvar__ = 3 12 | module.another_var = 4 13 | module.includeme = 42 14 | module.__all__ = ['another_var', 'includeme'] 15 | mock_resolve.return_value = module 16 | from nefertari import engine 17 | assert not hasattr(engine, 'log') 18 | assert not hasattr(engine, '__testvar__') 19 | assert not hasattr(engine, 'another_var') 20 | 21 | engine.includeme(config) 22 | 23 | config.include.assert_called_once_with('foo') 24 | mock_resolve.assert_called_with('foo') 25 | assert not hasattr(engine, 'log') 26 | assert not hasattr(engine, '__testvar__') 27 | assert hasattr(engine, 'another_var') 28 | assert engine.engines == (module, ) 29 | 30 | @patch('nefertari.engine.resolve') 31 | def test_multiple_engines(self, mock_resolve): 32 | from nefertari import engine 33 | foo = Mock() 34 | bar = Mock() 35 | foo.__all__ = ['one', 'two'] 36 | bar.__all__ = ['three', 'four'] 37 | config = Mock() 38 | config.registry.settings = {'nefertari.engine': ['foo', 'bar']} 39 | mock_resolve.side_effect = lambda m: foo if m == 'foo' else bar 40 | engine.includeme(config) 41 | 42 | config.include.assert_any_call('foo') 43 | config.include.assert_any_call('bar') 44 | mock_resolve.assert_any_call('foo') 45 | mock_resolve.assert_any_call('bar') 46 | assert not hasattr(engine, 'three') 47 | assert not hasattr(engine, 'four') 48 | assert hasattr(engine, 'one') 49 | assert hasattr(engine, 'two') 50 | assert engine.engines == (foo, bar) 51 | -------------------------------------------------------------------------------- /nefertari/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from pkg_resources import get_distribution 4 | 5 | APP_NAME = __package__.split('.')[0] 6 | _DIST = get_distribution(APP_NAME) 7 | PROJECTDIR = _DIST.location 8 | __version__ = _DIST.version 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | RESERVED_PARAMS = [ 13 | '_start', 14 | '_limit', 15 | '_page', 16 | '_fields', 17 | '_count', 18 | '_sort', 19 | '_search_fields', 20 | '_refresh_index', 21 | ] 22 | 23 | 24 | def includeme(config): 25 | from nefertari.resource import get_root_resource, get_resource_map 26 | from nefertari.renderers import ( 27 | JsonRendererFactory, NefertariJsonRendererFactory) 28 | from nefertari.utils import dictset 29 | from nefertari.events import ( 30 | ModelClassIs, FieldIsChanged, subscribe_to_events, 31 | add_field_processors) 32 | 33 | log.info("%s %s" % (APP_NAME, __version__)) 34 | config.add_directive('get_root_resource', get_root_resource) 35 | config.add_directive('subscribe_to_events', subscribe_to_events) 36 | config.add_directive('add_field_processors', add_field_processors) 37 | config.add_renderer('json', JsonRendererFactory) 38 | config.add_renderer('nefertari_json', NefertariJsonRendererFactory) 39 | 40 | if not hasattr(config.registry, '_root_resources'): 41 | config.registry._root_resources = {} 42 | if not hasattr(config.registry, '_resources_map'): 43 | config.registry._resources_map = {} 44 | # Map of {ModelName: model_collection_resource} 45 | if not hasattr(config.registry, '_model_collections'): 46 | config.registry._model_collections = {} 47 | 48 | config.add_request_method(get_resource_map, 'resource_map', reify=True) 49 | 50 | config.add_tween('nefertari.tweens.cache_control') 51 | 52 | config.add_subscriber_predicate('model', ModelClassIs) 53 | config.add_subscriber_predicate('field', FieldIsChanged) 54 | 55 | Settings = dictset(config.registry.settings) 56 | root = config.get_root_resource() 57 | root.auth = Settings.asbool('auth') 58 | -------------------------------------------------------------------------------- /nefertari/engine.py: -------------------------------------------------------------------------------- 1 | """ 2 | Extend global scope with engine-specific variables/objects. 3 | 4 | Usage 5 | ----- 6 | 7 | 0. Provide 'nefertari.engine' setting in your .ini 8 | 1. Include 'nefertari.engine' in your app's root 'includeme' as soon after 9 | `Configurator` setup as possible and BEFORE importing anything from 10 | nefertari.engine 11 | <- At this point you may import from nefertari.engine -> 12 | 2. Include your models 13 | 3. Perform database schema, engine, etc setup. Or use 14 | `nefertari.engine.setup_database`. 15 | 16 | Notes 17 | ----- 18 | 19 | Db setup should be performed after loading models, as some engines require 20 | model schemas to be defined before creating the database. If your database 21 | does not have the above requirement, it's up to you to decide when to set up 22 | the db. 23 | 24 | The specified engine module is also `config.include`d here, thus running the 25 | engine's `includeme` function and allowing setting up required state, 26 | performing some actions, etc. 27 | 28 | The engine specified may be either a module or a package. 29 | In case you build a custom engine, variables you expect to use from it 30 | should be importable from the package itself. 31 | E.g. ``from your.package import BaseDocument`` 32 | 33 | nefertari relies on 'nefertari.engine' being included when configuring the app. 34 | """ 35 | import sys 36 | from zope.dottedname.resolve import resolve 37 | from pyramid.settings import aslist 38 | 39 | 40 | def includeme(config): 41 | engine_paths = aslist(config.registry.settings['nefertari.engine']) 42 | for path in engine_paths: 43 | config.include(path) 44 | _load_engines(config) 45 | main_engine_module = engines[0] 46 | _import_public_names(main_engine_module) 47 | 48 | 49 | # replaced by registered engine modules during configuration 50 | engines = () 51 | 52 | 53 | def _load_engines(config): 54 | global engines 55 | engine_paths = aslist(config.registry.settings['nefertari.engine']) 56 | engines = tuple([resolve(path) for path in engine_paths]) 57 | 58 | 59 | def _import_public_names(module): 60 | "Import public names from module into this module, like import *" 61 | self = sys.modules[__name__] 62 | for name in module.__all__: 63 | if hasattr(self, name): 64 | # don't overwrite existing names 65 | continue 66 | setattr(self, name, getattr(module, name)) 67 | -------------------------------------------------------------------------------- /nefertari/scaffolds/nefertari_starter/+package+/__init__.py_tmpl: -------------------------------------------------------------------------------- 1 | from pkg_resources import get_distribution 2 | import logging 3 | 4 | from pyramid.config import Configurator 5 | 6 | import nefertari 7 | from nefertari.utils import dictset 8 | 9 | APP_NAME = __package__.split('.')[0] 10 | _DIST = get_distribution(APP_NAME) 11 | PROJECTDIR = _DIST.location 12 | __version__ = _DIST.version 13 | 14 | log = logging.getLogger(__name__) 15 | 16 | Settings = dictset() 17 | 18 | 19 | def bootstrap(config): 20 | Settings.update(config.registry.settings) 21 | Settings[APP_NAME + '.__version__'] = __version__ 22 | Settings[nefertari.APP_NAME+'.__version__'] = nefertari.__version__ 23 | 24 | config.include('nefertari') 25 | 26 | config.include('{{package}}.models') 27 | config.include('nefertari.view') 28 | config.include('nefertari.elasticsearch') 29 | config.include('nefertari.json_httpexceptions') 30 | 31 | if Settings.asbool('enable_get_tunneling'): 32 | config.add_tween('nefertari.tweens.get_tunneling') 33 | 34 | def _route_url(request, route_name, *args, **kw): 35 | if config.route_prefix: 36 | route_name = '%s_%s' % (config.route_prefix, route_name) 37 | return request.route_url(route_name, *args, **kw) 38 | 39 | config.add_request_method(_route_url) 40 | 41 | def _route_path(request, route_name, *args, **kw): 42 | if config.route_prefix: 43 | route_name = '%s_%s' % (config.route_prefix, route_name) 44 | return request.route_path(route_name, *args, **kw) 45 | 46 | config.add_request_method(_route_path) 47 | 48 | 49 | def main(global_config, **settings): 50 | Settings.update(settings) 51 | Settings.update(global_config) 52 | 53 | config = Configurator(settings=settings) 54 | config.include('nefertari.engine') 55 | config.include(includeme) 56 | 57 | from nefertari.engine import setup_database 58 | setup_database(config) 59 | 60 | from nefertari.elasticsearch import ES 61 | ES.setup_mappings() 62 | 63 | config.commit() 64 | 65 | return config.make_wsgi_app() 66 | 67 | 68 | def includeme(config): 69 | log.info("%s %s" % (APP_NAME, __version__)) 70 | 71 | bootstrap(config) 72 | 73 | config.scan(package='{{package}}.views') 74 | 75 | create_resources(config) 76 | 77 | 78 | def create_resources(config): 79 | from {{package}}.models import Item 80 | root = config.get_root_resource() 81 | 82 | root.add( 83 | 'item', 'items', 84 | id_name='item_' + Item.pk_field()) 85 | -------------------------------------------------------------------------------- /nefertari/scripts/post2api.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import json 3 | import requests 4 | import sys 5 | import getopt 6 | 7 | 8 | def _jdefault(obj): 9 | return obj.__dict__ 10 | 11 | 12 | def load(inputfile, destination): 13 | json_file = open(inputfile) 14 | json_data = json.load(json_file) 15 | 16 | for i in json_data: 17 | data = json.dumps(i, default=_jdefault) 18 | print('Posting: %s' % data) 19 | r = requests.post( 20 | destination, 21 | data=data, 22 | headers={'Content-type': 'application/json'}) 23 | print(r.status_code) 24 | 25 | json_file.close() 26 | 27 | 28 | def load_singular_objects(inputfile, destination): 29 | parent_route, dynamic_part = destination.split('{') 30 | parent_route = parent_route.strip('/') 31 | pk_field, singlular_field = dynamic_part.split('}') 32 | singlular_field = singlular_field.strip('/') 33 | 34 | json_file = open(inputfile) 35 | json_data = json.load(json_file) 36 | objects_count = len(json_data) 37 | 38 | query_string = '?_limit={}'.format(objects_count) 39 | parent_objects = requests.get(parent_route + query_string).json()['data'] 40 | 41 | for parent in parent_objects: 42 | print(parent_route) 43 | parent_url = parent['_self'].replace(query_string, '') 44 | singular_url = parent_url + '/' + singlular_field 45 | child = json_data.pop() 46 | data = json.dumps(child, default=_jdefault) 47 | print('Posting: {} to {}'.format(data, singular_url)) 48 | r = requests.post( 49 | singular_url, 50 | data=data, 51 | headers={'Content-type': 'application/json'}) 52 | print(r.status_code) 53 | 54 | 55 | def main(): 56 | argv = sys.argv[1:] 57 | try: 58 | opts, args = getopt.getopt(argv, 'hf:u:', ['help', 'file=', 'url=']) 59 | except getopt.GetoptError: 60 | usage() 61 | sys.exit(2) 62 | 63 | for opt, arg in opts: 64 | if opt == '-h': 65 | usage() 66 | sys.exit() 67 | elif opt in ('-f', '--file'): 68 | inputfile = arg 69 | elif opt in ('-u', '--url'): 70 | destination = arg 71 | 72 | try: 73 | inputfile 74 | destination 75 | except NameError: 76 | usage() 77 | sys.exit() 78 | 79 | if '{' in destination and not destination.endswith('}'): 80 | # E.g. /users/{username}/profile 81 | load_singular_objects(inputfile, destination) 82 | else: 83 | # E.g. /users 84 | load(inputfile, destination) 85 | 86 | 87 | def usage(): 88 | print('Usage: nefertari.post2api -f -u ') 89 | 90 | 91 | if __name__ == '__main__': 92 | main() 93 | -------------------------------------------------------------------------------- /nefertari/scripts/scaffold_test.py: -------------------------------------------------------------------------------- 1 | """ Reworked version of Pyramid scaffold "tests" module. 2 | 3 | https://github.com/Pylons/pyramid/blob/master/pyramid/scaffolds/tests.py 4 | """ 5 | import sys 6 | import os 7 | import shutil 8 | import tempfile 9 | from subprocess import check_call, Popen, PIPE 10 | from argparse import ArgumentParser 11 | 12 | 13 | class ScaffoldTestCommand(object): 14 | # Engine codes when creating an app from scaffold 15 | SQLA_ENGINE_CODE = '1' 16 | MONGO_ENGINE_CODE = '2' 17 | ENGINE = MONGO_ENGINE_CODE 18 | file = __file__ 19 | 20 | def make_venv(self, directory): # pragma: no cover 21 | import virtualenv 22 | from virtualenv import Logger 23 | logger = Logger([(Logger.level_for_integer(2), sys.stdout)]) 24 | virtualenv.logger = logger 25 | virtualenv.create_environment(directory, 26 | site_packages=False, 27 | clear=False, 28 | unzip_setuptools=True) 29 | 30 | def run_tests(self, scaff_name): # pragma: no cover 31 | proj_name = scaff_name.title() 32 | orig_path = os.environ.get('PATH', '') 33 | try: 34 | self.old_cwd = os.getcwd() 35 | self.directory = tempfile.mkdtemp() 36 | self.make_venv(self.directory) 37 | os.environ['PATH'] = os.path.join( 38 | self.directory, 'bin') + ':' + orig_path 39 | 40 | # Install library in created env 41 | here = os.path.abspath(os.path.dirname(self.file)) 42 | os.chdir(os.path.dirname(os.path.dirname(here))) 43 | check_call(['python', 'setup.py', 'develop']) 44 | os.chdir(self.directory) 45 | 46 | # Create app from scaffold and install it 47 | popen = Popen( 48 | ['bin/pcreate', '-s', scaff_name, proj_name], 49 | stdin=PIPE, stdout=PIPE, 50 | universal_newlines=True) 51 | popen.communicate(self.ENGINE) 52 | os.chdir(proj_name) 53 | check_call(['python', 'setup.py', 'install']) 54 | 55 | # Install test requirements 56 | test_reqs = os.path.join(scaff_name, 'tests', 'requirements.txt') 57 | if os.path.exists(test_reqs): 58 | check_call(['pip', 'install', '-r', test_reqs]) 59 | 60 | # Run actual scaffold tests 61 | check_call(['py.test', scaff_name]) 62 | finally: 63 | os.environ['PATH'] = orig_path 64 | shutil.rmtree(self.directory) 65 | os.chdir(self.old_cwd) 66 | 67 | def parse_args(self): 68 | parser = ArgumentParser() 69 | parser.add_argument( 70 | '-s', '--scaffold', help='Scaffold name', 71 | required=True) 72 | self.args = parser.parse_args() 73 | 74 | def run(self): 75 | self.parse_args() 76 | self.run_tests(self.args.scaffold) 77 | 78 | 79 | def main(*args, **kwargs): 80 | ScaffoldTestCommand().run() 81 | 82 | 83 | if __name__ == '__main__': 84 | main() 85 | -------------------------------------------------------------------------------- /docs/source/views.rst: -------------------------------------------------------------------------------- 1 | Configuring Views 2 | ================= 3 | 4 | .. code-block:: python 5 | 6 | from nefertari.view import BaseView 7 | from example_api.models import Story 8 | 9 | 10 | class StoriesView(BaseView): 11 | Model = Story 12 | 13 | def index(self): 14 | return self.get_collection_es() 15 | 16 | def show(self, **kwargs): 17 | return self.context 18 | 19 | def create(self): 20 | story = self.Model(**self._json_params) 21 | return story.save(self.request) 22 | 23 | def update(self, **kwargs): 24 | story = self.Model.get_item( 25 | id=kwargs.pop('story_id'), **kwargs) 26 | return story.update(self._json_params, self.request) 27 | 28 | def replace(self, **kwargs): 29 | return self.update(**kwargs) 30 | 31 | def delete(self, **kwargs): 32 | story = self.Model.get_item( 33 | id=kwargs.pop('story_id'), **kwargs) 34 | story.delete(self.request) 35 | 36 | def delete_many(self): 37 | es_stories = self.get_collection_es() 38 | stories = self.Model.filter_objects(es_stories) 39 | 40 | return self.Model._delete_many(stories, self.request) 41 | 42 | def update_many(self): 43 | es_stories = self.get_collection_es() 44 | stories = self.Model.filter_objects(es_stories) 45 | 46 | return self.Model._update_many( 47 | stories, self._json_params, self.request) 48 | 49 | * ``index()`` called upon ``GET`` request to a collection, e.g. ``/collection`` 50 | * ``show()`` called upon ``GET`` request to a collection-item, e.g. ``/collection/`` 51 | * ``create()`` called upon ``POST`` request to a collection 52 | * ``update()`` called upon ``PATCH`` request to a collection-item 53 | * ``replace()`` called upon ``PUT`` request to a collection-item 54 | * ``delete()`` called upon ``DELETE`` request to a collection-item 55 | * ``update_many()`` called upon ``PATCH`` request to a collection or filtered collection 56 | * ``delete_many()`` called upon ``DELETE`` request to a collection or filtered collection 57 | 58 | 59 | Polymorphic Views 60 | ----------------- 61 | 62 | Set ``elasticsearch.enable_polymorphic_query = true`` in your .ini file to enable this feature. Polymorphic views are views that return two or more comma-separated collections, e.g.`/api/,`. They are dynamic which means that they do not need to be defined in your code. 63 | 64 | 65 | Other Considerations 66 | -------------------- 67 | 68 | It is recommended that your views reside in a package: 69 | In this case, each module of that package would contain all views of any given root-level route. Alternatively, ou can explicitly provide a view name, or a view class as a ``view`` keyword argument to ``resource.add()`` in your project's ``main`` function. 70 | 71 | For singular resources: 72 | there is no need to define ``index()`` 73 | 74 | Each view must define the following property: 75 | *Model*: model being served by the current view. Must be set at class definition for features to work properly. E.g.: 76 | 77 | .. code-block:: python 78 | 79 | 80 | from nefertari.view import BaseView 81 | from example_api.models import Story 82 | 83 | class StoriesView(BaseView): 84 | Model = Story 85 | 86 | Optional properties: 87 | *_json_encoder*: encoder to encode objects to JSON. Database-specific encoders are available at ``nefertari.engine.JSONEncoder`` 88 | -------------------------------------------------------------------------------- /nefertari/acl.py: -------------------------------------------------------------------------------- 1 | from pyramid.security import( 2 | ALL_PERMISSIONS, 3 | Allow, 4 | Everyone, 5 | Authenticated, 6 | ) 7 | 8 | 9 | class Contained(object): 10 | """Contained base class resource 11 | 12 | Can inherit its acl from its parent. 13 | """ 14 | 15 | def __init__(self, request, name='', parent=None): 16 | self.request = request 17 | self.__name__ = name 18 | self.__parent__ = parent 19 | 20 | 21 | class CollectionACL(Contained): 22 | """Collection resource. 23 | 24 | You must specify the ``item_model``. It should be a nefertari.engine 25 | document class. It is the model class for collection items. 26 | 27 | Define a ``__acl__`` attribute on this class to define the container's 28 | permissions, and default child permissions. Inherits its acl from the 29 | root, if no acl is set. 30 | 31 | Override the `item_acl` method if you wish to provide custom acls for 32 | collection items. 33 | 34 | Override the `item_db_id` method if you wish to transform the collection 35 | item db id, e.g. to support a ``self`` item on a user collection. 36 | """ 37 | 38 | __acl__ = ( 39 | (Allow, 'g:admin', ALL_PERMISSIONS), 40 | ) 41 | 42 | item_model = None 43 | 44 | def __getitem__(self, key): 45 | db_id = self.item_db_id(key) 46 | pk_field = self.item_model.pk_field() 47 | try: 48 | item = self.item_model.get_item( 49 | __raise=True, **{pk_field: db_id} 50 | ) 51 | except AttributeError: 52 | # strangely we get an AttributeError when the item isn't found 53 | raise KeyError(key) 54 | acl = self.item_acl(item) 55 | if acl is not None: 56 | item.__acl__ = acl 57 | item.__parent__ = self 58 | item.__name__ = key 59 | return item 60 | 61 | def item_acl(self, item): 62 | return None 63 | 64 | def item_db_id(self, key): 65 | return key 66 | 67 | 68 | def authenticated_userid(request): 69 | """Helper function that can be used in ``db_key`` to support `self` 70 | as a collection key. 71 | """ 72 | user = getattr(request, 'user', None) 73 | key = user.pk_field() 74 | return getattr(user, key) 75 | 76 | 77 | # Example ACL classes and base classes 78 | # 79 | 80 | class RootACL(Contained): 81 | __acl__ = ( 82 | (Allow, 'g:admin', ALL_PERMISSIONS), 83 | ) 84 | 85 | 86 | class GuestACL(CollectionACL): 87 | """Guest level ACL base class 88 | 89 | Gives read permissions to everyone. 90 | """ 91 | __acl__ = ( 92 | (Allow, 'g:admin', ALL_PERMISSIONS), 93 | (Allow, Everyone, ('view', 'options')), 94 | ) 95 | 96 | 97 | class AuthenticatedReadACL(CollectionACL): 98 | """ Authenticated users ACL base class 99 | 100 | Gives read access to all Authenticated users. 101 | Gives delete, create, update access to admin only. 102 | """ 103 | __acl__ = ( 104 | (Allow, 'g:admin', ALL_PERMISSIONS), 105 | (Allow, Authenticated, ('view', 'options')), 106 | ) 107 | 108 | 109 | class AuthenticationACL(Contained): 110 | """ Special ACL factory to be used with authentication views 111 | (login, logout, register, etc.) 112 | 113 | Allows create, view and option methods to everyone. 114 | """ 115 | 116 | __acl__ = ( 117 | (Allow, 'g:admin', ALL_PERMISSIONS), 118 | (Allow, Everyone, ('create', 'view', 'options')), 119 | ) 120 | -------------------------------------------------------------------------------- /nefertari/json_httpexceptions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | import traceback 4 | from datetime import datetime 5 | 6 | import six 7 | from pyramid import httpexceptions as http_exc 8 | 9 | from nefertari.wrappers import apply_privacy 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def includeme(config): 16 | config.add_view(view=httperrors, context=http_exc.HTTPError) 17 | logger.info('Include json_httpexceptions') 18 | 19 | 20 | STATUS_MAP = dict() 21 | BLACKLIST_LOG = [404] 22 | BASE_ATTRS = ['status_code', 'explanation', 'message', 'title'] 23 | 24 | 25 | def add_stack(): 26 | return ''.join(traceback.format_stack()) 27 | 28 | 29 | def create_json_response(obj, request=None, log_it=False, show_stack=False, 30 | **extra): 31 | from nefertari.utils import json_dumps 32 | body = extra.pop('body', None) 33 | encoder = extra.pop('encoder', None) 34 | 35 | if body is None: 36 | body = dict() 37 | for attr in BASE_ATTRS: 38 | body[attr] = extra.pop(attr, None) or getattr(obj, attr, None) 39 | 40 | extra['timestamp'] = datetime.utcnow() 41 | if request: 42 | extra['request_url'] = request.url 43 | if obj.status_int in [403, 401]: 44 | extra['client_addr'] = request.client_addr 45 | extra['remote_addr'] = request.remote_addr 46 | 47 | if obj.location: 48 | body['_pk'] = obj.location.split('/')[-1] 49 | body.update(extra) 50 | 51 | obj.body = six.b(json_dumps(body, encoder=encoder)) 52 | show_stack = log_it or show_stack 53 | status = obj.status_int 54 | 55 | if 400 <= status < 600 and status not in BLACKLIST_LOG or log_it: 56 | msg = '%s: %s' % (obj.status.upper(), obj.body) 57 | if obj.status_int in [400, 500] or show_stack: 58 | msg += '\nSTACK BEGIN>>\n%s\nSTACK END<<' % add_stack() 59 | 60 | logger.error(msg) 61 | 62 | obj.content_type = 'application/json' 63 | return obj 64 | 65 | 66 | def exception_response(status_code, **kw): 67 | return STATUS_MAP[status_code](**kw) 68 | 69 | 70 | class JBase(object): 71 | def __init__(self, *arg, **kw): 72 | from nefertari.utils import dictset 73 | kw = dictset(kw) 74 | self.__class__.__base__.__init__( 75 | self, *arg, 76 | **kw.subset(BASE_ATTRS+['headers', 'location'])) 77 | 78 | create_json_response(self, **kw) 79 | 80 | 81 | thismodule = sys.modules[__name__] 82 | 83 | 84 | http_exceptions = list(http_exc.status_map.values()) + [ 85 | http_exc.HTTPBadRequest, 86 | http_exc.HTTPInternalServerError, 87 | ] 88 | 89 | 90 | for exc_cls in http_exceptions: 91 | name = "J%s" % exc_cls.__name__ 92 | STATUS_MAP[exc_cls.code] = type(name, (JBase, exc_cls), {}) 93 | setattr(thismodule, name, STATUS_MAP[exc_cls.code]) 94 | 95 | 96 | def httperrors(context, request): 97 | return create_json_response(context, request=request) 98 | 99 | 100 | class JHTTPCreated(http_exc.HTTPCreated): 101 | def __init__(self, *args, **kwargs): 102 | resource = kwargs.pop('resource', None) 103 | resp_kwargs = { 104 | 'obj': self, 105 | 'request': kwargs.pop('request', None), 106 | 'encoder': kwargs.pop('encoder', None), 107 | 'body': kwargs.pop('body', None), 108 | 'resource': resource, 109 | } 110 | super(JHTTPCreated, self).__init__(*args, **kwargs) 111 | 112 | if resource and 'location' in kwargs: 113 | resource['_self'] = kwargs['location'] 114 | 115 | create_json_response(**resp_kwargs) 116 | -------------------------------------------------------------------------------- /docs/source/why.rst: -------------------------------------------------------------------------------- 1 | Why Nefertari? 2 | ============== 3 | 4 | Nefertari is a tool for making REST APIs using the Pyramid web framework. 5 | 6 | 7 | Rationale 8 | --------- 9 | 10 | There are many other libraries that make writing REST APIs easy. Nefertari did not begin as a tool. It was extracted from the API powering `Brandicted `_ after almost two years of development. Part of this decision was based on thinking highly of using Elasticsearch-powered views for endpoints, after having had a great experience with it. 11 | 12 | We wanted to build powerful REST APIs that are relatively opinionated but still flexible (in order to make easy things easy and hard things possible). We happened to need to use Postgres on a side project, but Brandicted's API only supported MongoDB. 13 | 14 | Before extracting Nefertari and turning it into an open source project, we shopped around the Python ecosystem and tested every REST API library/framework to see what would allow us to be as lazy as possible and also allow our APIs to grow bigger over time. 15 | 16 | The most convenient option was the beautiful `flask-restless `_ by Jeffrey Finkelstein. It depends on SQLAlchemy and does a really good job being super easy to use. We had some subjective reservations about using Flask because of globals and the fact that our closest community members happen to be Pyramid folks. Also, ditching Elasticsearch would have meant needing to write queries in views. 17 | 18 | We were also inspired by `pyramid-royal `_ from our fellow Montreal Python colleague Hadrien David. He showed how traversal is a-ok for matching routes in a tree of resources, which is what REST should be anyway. 19 | 20 | However, we had become quite used to the power of Elasticsearch and wanted to retain the option of using it as a first class citizen to power most GET views. Plus we were already using traversal without really thinking. Therefore we decided to add Postgres support via SQLA to our platform, and thus was born Nefertari. 21 | 22 | 23 | Vision 24 | ------ 25 | 26 | To us, "REST API" means something like "HTTP verbs mapped to CRUD operations on resources described as JSON". We are not trying to do full-on `HATEOAS `_ to satisfy any academic ideal of the REST style. There are quite a few development tasks that can be abstracted away by using our simple definition. This might change in the future when there's more of an ecosystem around client libraries which would know how to browse/consume hypermedia. 27 | 28 | By making assumptions about sane defaults, we can eliminate the need for boilerplate to do things like serialization, URL mapping, validation, authentication/authorization, versioning, testing, database queries in views, etc. The only things that should absolutely need to be defined are the resources themselves, and they should just know how to act in a RESTful way by default. They should be configurable to a degree and extendable in special cases. Contrast this idea with something like the `Django Rest Framework `_ where quite a number of things need to be laid out in order to create an endpoint. [#]_ 29 | 30 | Nefertari is the meat and potatoes of our development stack. Her partner project, Ramses, is the seasoning/sugar/cherry on top! Ramses allows whole production-ready Nefertari apps to be generated at runtime from a simple YAML file specifying the endpoints desired. `Check it out. `_ 31 | 32 | .. [#] For the record, DRF is pretty badass and we have great respect for its vision and breadth, and the hard work of its community. Laying out a ton of boilerplate can be considered to fall into "flat is better than nested" or "explicit is better than implicit" and might be best for some teams. 33 | -------------------------------------------------------------------------------- /tests/test_acl.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from mock import Mock 3 | from pyramid.security import ( 4 | ALL_PERMISSIONS, 5 | Allow, 6 | Authenticated, 7 | Deny, 8 | Everyone, 9 | ) 10 | 11 | from nefertari import acl 12 | 13 | 14 | class DummyModel(object): 15 | @staticmethod 16 | def pk_field(): 17 | return 'id' 18 | @classmethod 19 | def get_item(cls, id, **kw): 20 | i = cls() 21 | i.id = id 22 | return i 23 | 24 | 25 | class TestACLsUnit(object): 26 | 27 | def dummy_acl(self): 28 | class DummyACL(acl.CollectionACL): 29 | item_model = DummyModel 30 | return DummyACL 31 | 32 | def test_default_acl(self): 33 | acl = self.dummy_acl() 34 | obj = acl(request='foo') 35 | assert obj.__acl__ == (('Allow', 'g:admin', ALL_PERMISSIONS),) 36 | 37 | def test_inherit_acl(self): 38 | acl = self.dummy_acl() 39 | item = acl(request='foo')['name'] 40 | assert getattr(item, '__acl__', None) == None 41 | 42 | def test_item(self): 43 | acl = self.dummy_acl() 44 | item = acl(request='foo')['item-id'] 45 | assert item.id == 'item-id' 46 | assert isinstance(item, acl.item_model) 47 | 48 | def test_custom_acl(self): 49 | class DummyACL(acl.CollectionACL): 50 | item_model = DummyModel 51 | def item_acl(self, item): 52 | return ( 53 | (Allow, item.id, 'update'), 54 | ) 55 | r = DummyACL(request='foo') 56 | item = r['item-id'] 57 | assert item.__acl__ == ((Allow, 'item-id', 'update'),) 58 | 59 | def test_db_id(self): 60 | class DummyACL(acl.CollectionACL): 61 | item_model = DummyModel 62 | def item_db_id(self, key): 63 | if key == 'self': 64 | return 42 65 | return key 66 | r = DummyACL(request='foo') 67 | item = r['item-id'] 68 | assert item.id == 'item-id' 69 | item = r['self'] 70 | assert item.id == 42 71 | 72 | def test_self_db_id(self): 73 | from nefertari.acl import authenticated_userid 74 | class DBClass(object): 75 | @staticmethod 76 | def pk_field(): 77 | return 'id' 78 | @staticmethod 79 | def get_item(id, **kw): 80 | return id 81 | class UserACL(acl.CollectionACL): 82 | item_model = DummyModel 83 | def item_db_id(self, key): 84 | if self.request.user is not None and key == 'self': 85 | return authenticated_userid(self.request) 86 | return key 87 | user = Mock(username='user12') 88 | user.pk_field.return_value = 'username' 89 | req = Mock(user=user) 90 | obj = UserACL(request=req)['self'] 91 | assert obj.id == 'user12' 92 | 93 | def test_item_404(self): 94 | class NotFoundModel(DummyModel): 95 | @staticmethod 96 | def get_item(id, **kw): 97 | raise AttributeError() 98 | class DummyACL(acl.CollectionACL): 99 | item_model = NotFoundModel 100 | with pytest.raises(KeyError): 101 | DummyACL(request='foo')['item-id'] 102 | 103 | def test_rootacl(self): 104 | acl_obj = acl.RootACL(request='foo') 105 | assert acl_obj.__acl__ == ((Allow, 'g:admin', ALL_PERMISSIONS),) 106 | assert acl_obj.request == 'foo' 107 | 108 | def test_guestacl_acl(self): 109 | acl_obj = acl.GuestACL(request='foo') 110 | assert acl_obj.__acl__ == ( 111 | (Allow, 'g:admin', ALL_PERMISSIONS), 112 | (Allow, Everyone, ('view', 'options')) 113 | ) 114 | 115 | def test_authenticatedreadacl_acl(self): 116 | acl_obj = acl.AuthenticatedReadACL(request='foo') 117 | assert acl_obj.__acl__ == ( 118 | (Allow, 'g:admin', ALL_PERMISSIONS), 119 | (Allow, Authenticated, ('view', 'options')) 120 | ) 121 | -------------------------------------------------------------------------------- /nefertari/scaffolds/nefertari_starter/+package+/tests/api.raml: -------------------------------------------------------------------------------- 1 | #%RAML 0.8 2 | --- 3 | title: Items API 4 | documentation: 5 | - title: Items REST API 6 | content: | 7 | Welcome to the Items API. 8 | baseUri: http://{host}:{port}/{version} 9 | version: api 10 | mediaType: application/json 11 | protocols: [HTTP] 12 | 13 | /items: 14 | displayName: Collection of items 15 | head: 16 | description: Head request 17 | responses: 18 | 200: 19 | description: Return headers 20 | options: 21 | description: Options request 22 | responses: 23 | 200: 24 | description: Return available HTTP methods 25 | get: 26 | description: Get all item 27 | responses: 28 | 200: 29 | description: Returns a list of items 30 | post: 31 | description: Create a new item 32 | body: 33 | application/json: 34 | schema: !include items.json 35 | example: | 36 | { 37 | "id": "507f191e810c19729de860ea", 38 | "name": "Banana", 39 | "description": "Tasty" 40 | } 41 | responses: 42 | 201: 43 | description: Created item 44 | body: 45 | application/json: 46 | schema: !include items.json 47 | delete: 48 | description: Delete multiple items 49 | responses: 50 | 200: 51 | description: Deleted multiple items 52 | patch: 53 | description: Update multiple items 54 | body: 55 | application/json: 56 | example: { "name": "Car" } 57 | responses: 58 | 200: 59 | description: Updated multiple items 60 | put: 61 | description: Update multiple items 62 | body: 63 | application/json: 64 | example: { "name": "Plane" } 65 | responses: 66 | 200: 67 | description: Updated multiple items 68 | 69 | /{id}: 70 | uriParameters: 71 | id: 72 | displayName: Item id 73 | type: string 74 | example: 507f191e810c19729de860ea 75 | displayName: Collection-item 76 | head: 77 | description: Head request 78 | responses: 79 | 200: 80 | description: Return headers 81 | options: 82 | description: Options request 83 | responses: 84 | 200: 85 | description: Return available HTTP methods 86 | get: 87 | description: Get a particular item 88 | responses: 89 | 200: 90 | body: 91 | application/json: 92 | schema: !include items.json 93 | 94 | delete: 95 | description: Delete a particular item 96 | responses: 97 | 200: 98 | description: Deleted item 99 | patch: 100 | description: Update a particular item 101 | body: 102 | application/json: 103 | example: { "name": "Tree" } 104 | responses: 105 | 200: 106 | body: 107 | application/json: 108 | schema: !include items.json 109 | put: 110 | description: Replace a particular item 111 | body: 112 | application/json: 113 | example: | 114 | { 115 | "id": "507f191e810c19729de860ea", 116 | "name": "Horse", 117 | "description": "Not tasty" 118 | } 119 | responses: 120 | 200: 121 | body: 122 | application/json: 123 | schema: !include items.json -------------------------------------------------------------------------------- /nefertari/utils/data.py: -------------------------------------------------------------------------------- 1 | import six 2 | from nefertari.utils.dictset import dictset 3 | from nefertari.utils.utils import issequence 4 | 5 | 6 | class DataProxy(object): 7 | def __init__(self, data={}): 8 | self._data = dictset(data) 9 | 10 | def to_dict(self, **kwargs): 11 | _dict = dictset() 12 | _keys = kwargs.pop('_keys', []) 13 | _depth = kwargs.pop('_depth', 1) 14 | 15 | data = dictset(self._data).subset(_keys) if _keys else self._data 16 | 17 | for attr, val in data.items(): 18 | _dict[attr] = val 19 | if _depth: 20 | kw = kwargs.copy() 21 | kw['_depth'] = _depth - 1 22 | 23 | if hasattr(val, 'to_dict'): 24 | _dict[attr] = val.to_dict(**kw) 25 | elif isinstance(val, list): 26 | _dict[attr] = to_dicts(val, **kw) 27 | 28 | _dict['_type'] = self.__class__.__name__ 29 | return _dict 30 | 31 | 32 | def dict2obj(data): 33 | if not data: 34 | return data 35 | 36 | _type = str(data.get('_type')) 37 | top = type(_type, (DataProxy,), {})(data) 38 | 39 | for key, val in top._data.items(): 40 | key = str(key) 41 | if isinstance(val, dict): 42 | setattr(top, key, dict2obj(val)) 43 | elif isinstance(val, list): 44 | setattr( 45 | top, key, 46 | [dict2obj(sj) if isinstance(sj, dict) else sj for sj in val]) 47 | else: 48 | setattr(top, key, val) 49 | 50 | return top 51 | 52 | 53 | def to_objs(collection): 54 | _objs = [] 55 | 56 | for each in collection: 57 | _objs.append(dict2obj(each)) 58 | 59 | return _objs 60 | 61 | 62 | def to_dicts(collection, key=None, **kw): 63 | _dicts = [] 64 | try: 65 | for each in collection: 66 | try: 67 | each_dict = each.to_dict(**kw) 68 | if key: 69 | each_dict = key(each_dict) 70 | _dicts.append(each_dict) 71 | except AttributeError: 72 | _dicts.append(each) 73 | except TypeError: 74 | return collection 75 | 76 | return _dicts 77 | 78 | 79 | def obj2dict(obj, classkey=None): 80 | if isinstance(obj, dict): 81 | for k in obj.keys(): 82 | obj[k] = obj2dict(obj[k], classkey) 83 | return obj 84 | elif issequence(obj): 85 | return [obj2dict(v, classkey) for v in obj] 86 | elif hasattr(obj, "__dict__"): 87 | data = dictset([ 88 | (key, obj2dict(value, classkey)) 89 | for key, value in obj.__dict__.items() 90 | if not six.callable(value) and not key.startswith('_') 91 | ]) 92 | if classkey is not None and hasattr(obj, "__class__"): 93 | data[classkey] = obj.__class__.__name__ 94 | return data 95 | else: 96 | return obj 97 | 98 | 99 | class FieldData(object): 100 | """ Keeps field data in a generic format. 101 | 102 | Is passed to field processors. 103 | """ 104 | def __init__(self, name, new_value, params=None): 105 | """ 106 | :param name: Name of field. 107 | :param new_value: New value of field. 108 | :param params: Dict containing DB field init params. 109 | E.g. min_length, required. 110 | """ 111 | self.name = name 112 | self.new_value = new_value 113 | self.params = params 114 | 115 | def __repr__(self): 116 | return ''.format(self.name) 117 | 118 | @classmethod 119 | def from_dict(cls, data, model): 120 | """ Generate map of `fieldName: clsInstance` from dict. 121 | 122 | :param data: Dict where keys are field names and values are 123 | new values of field. 124 | :param model: Model class to which fields from :data: belong. 125 | """ 126 | model_provided = model is not None 127 | result = {} 128 | for name, new_value in data.items(): 129 | kwargs = { 130 | 'name': name, 131 | 'new_value': new_value, 132 | } 133 | if model_provided: 134 | kwargs['params'] = model.get_field_params(name) 135 | result[name] = cls(**kwargs) 136 | return result 137 | -------------------------------------------------------------------------------- /docs/source/auth.rst: -------------------------------------------------------------------------------- 1 | Authentication & Security 2 | ========================= 3 | 4 | In order to enable authentication, add the ``auth`` paramer to your .ini file: 5 | 6 | .. code-block:: ini 7 | 8 | auth = true 9 | 10 | Nefertari currently uses the default Pyramid "auth ticket" cookie mechanism. 11 | 12 | 13 | Custom User Model 14 | ----------------- 15 | 16 | When authentication is enabled, Nefertari uses its own `User` model. This model has 4 fields by default: username, email, password and groups (list field with values 'admin' and 'user'). However, this model can be extanded. 17 | 18 | .. code-block:: python 19 | 20 | from nefertari import engine as eng 21 | from nefertari.authentication.models import AuthUserMixin 22 | from nefertari.engine import BaseDocument 23 | 24 | 25 | class User(AuthUserMixin, BaseDocument): 26 | __tablename__ = 'users' 27 | 28 | first_name = eng.StringField(max_length=50, default='') 29 | last_name = eng.StringField(max_length=50, default='') 30 | 31 | 32 | Visible Fields in Views 33 | ----------------------- 34 | 35 | You can control which fields to display by defining the following properties on your models: 36 | 37 | **_auth_fields** 38 | Lists fields to be displayed to authenticated users. 39 | 40 | **_public_fields** 41 | Lists fields to be displayed to all users including unauthenticated users. 42 | 43 | **_hidden_fields** 44 | Lists fields to be hidden but remain editable (as long as user has permission), e.g. password. 45 | 46 | 47 | Permissions 48 | ----------- 49 | 50 | This section describes permissions used by nefertari, their relation to view methods and HTTP methods. These permissions should be used when defining ACLs. 51 | 52 | To make things easier to grasp, let's imagine we have an application that defines a view which handles all possible requests under ``/products`` route. We are going to use this example to make permissions description more obvious. 53 | 54 | Following lists nefertari permissions along with HTTP methods and view methods they correspond to: 55 | 56 | **view** 57 | * Collection GET (``GET /products``). View method ``index`` 58 | * Item GET (``GET /products/1``) View method ``show`` 59 | * Collection HEAD (``HEAD /products``). View method ``index`` 60 | * Item HEAD (``HEAD /products/1``). View method ``show`` 61 | 62 | **create** 63 | * Collection POST (``POST /products``). View method ``create`` 64 | 65 | **update** 66 | * Collection PATCH (``PATCH /products``). View method ``update_many`` 67 | * Collection PUT (``PUT /products``). View method ``update_many`` 68 | * Item PATCH (``PATCH /products/1``). View method ``update`` 69 | * Item PUT (``PUT /products/1``) View method ``replace`` 70 | 71 | **delete** 72 | * Collection DELETE (``DELETE /products``). View method ``delete_many`` 73 | * Item DELETE (``DELETE /products/1``). View method ``delete`` 74 | 75 | **options** 76 | * Collection OPTIONS (``OPTIONS /products``). View method ``collection_options`` 77 | * Item OPTIONS (``OPTIONS /products/1``). View method ``item_options`` 78 | 79 | 80 | ACL API 81 | ------- 82 | 83 | For authorizing access to specific resources, Nefertari uses standard Pyramid access control lists. `See the documentation on Pyramid ACLs `_ to understand how to extend and customize them. 84 | 85 | Considerations: 86 | * An item will inherit its collection's permissions if the item's permissions are not specified in an ACL class 87 | * If you create an ACL class for your document that does something like give the document.owner edit permissions, then you can’t rely on this setting to be respected during collection operation. in other words, only if you walk up to the item via a URL will this permission setting be applied. 88 | 89 | .. automodule:: nefertari.acl 90 | :members: 91 | 92 | 93 | Advanced ACLs 94 | ------------- 95 | 96 | For more advanced ACLs, you can look into using `nefertari-guards `_ in you project. This package stores ACLs at the object level, making it easier to build multi-tenant applications using a single data store. 97 | 98 | 99 | CORS 100 | ---- 101 | 102 | To enable CORS headers, set the following lines in your .ini file: 103 | 104 | .. code-block:: ini 105 | 106 | cors.enable = true 107 | cors.allow_origins = http://localhost 108 | cors.allow_credentials = true 109 | -------------------------------------------------------------------------------- /nefertari/authentication/policies.py: -------------------------------------------------------------------------------- 1 | import six 2 | from pyramid.authentication import CallbackAuthenticationPolicy 3 | 4 | from nefertari import engine 5 | from .models import create_apikey_model 6 | 7 | 8 | class ApiKeyAuthenticationPolicy(CallbackAuthenticationPolicy): 9 | """ ApiKey authentication policy. 10 | 11 | Relies on `Authorization` header being used in request, e.g.: 12 | `Authorization: ApiKey username:token` 13 | 14 | To use this policy, instantiate it with required arguments, as described 15 | in `__init__` method and register it with Pyramid's 16 | `Configurator.set_authentication_policy`. 17 | 18 | You may also find useful `nefertari.authentication.views. 19 | TokenAuthenticationView` 20 | view which offers basic functionality to create, claim, and reset the 21 | token. 22 | """ 23 | def __init__(self, user_model, check=None, credentials_callback=None): 24 | """ Init the policy. 25 | 26 | Arguments: 27 | :user_model: String name or class of a User model for which ApiKey 28 | model is to be generated 29 | :check: A callback passed the username, api_key and the request, 30 | expected to return None if user doesn't exist or a sequence of 31 | principal identifiers (possibly empty) if the user does exist. 32 | If callback is None, the username will be assumed to exist with 33 | no principals. Optional. 34 | :credentials_callback: A callback passed the username and current 35 | request, expected to return and user's api key. 36 | Is used to generate 'WWW-Authenticate' header with a value of 37 | valid 'Authorization' request header that should be used to 38 | perform requests. 39 | """ 40 | self.user_model = user_model 41 | if isinstance(self.user_model, six.string_types): 42 | self.user_model = engine.get_document_cls(self.user_model) 43 | create_apikey_model(self.user_model) 44 | 45 | self.check = check 46 | self.credentials_callback = credentials_callback 47 | super(ApiKeyAuthenticationPolicy, self).__init__() 48 | 49 | def remember(self, request, username, **kw): 50 | """ Returns 'WWW-Authenticate' header with a value that should be used 51 | in 'Authorization' header. 52 | """ 53 | if self.credentials_callback: 54 | token = self.credentials_callback(username, request) 55 | api_key = 'ApiKey {}:{}'.format(username, token) 56 | return [('WWW-Authenticate', api_key)] 57 | 58 | def forget(self, request): 59 | """ Returns challenge headers. This should be attached to a response 60 | to indicate that credentials are required.""" 61 | return [('WWW-Authenticate', 'ApiKey realm="%s"' % self.realm)] 62 | 63 | def unauthenticated_userid(self, request): 64 | """ Username parsed from the ``Authorization`` request header.""" 65 | credentials = self._get_credentials(request) 66 | if credentials: 67 | return credentials[0] 68 | 69 | def callback(self, username, request): 70 | """ Having :username: return user's identifiers or None. """ 71 | credentials = self._get_credentials(request) 72 | if credentials: 73 | username, api_key = credentials 74 | if self.check: 75 | return self.check(username, api_key, request) 76 | 77 | def _get_credentials(self, request): 78 | """ Extract username and api key token from 'Authorization' header """ 79 | authorization = request.headers.get('Authorization') 80 | if not authorization: 81 | return None 82 | try: 83 | authmeth, authbytes = authorization.split(' ', 1) 84 | except ValueError: # not enough values to unpack 85 | return None 86 | if authmeth.lower() != 'apikey': 87 | return None 88 | 89 | if six.PY2 or isinstance(authbytes, bytes): 90 | try: 91 | auth = authbytes.decode('utf-8') 92 | except UnicodeDecodeError: 93 | auth = authbytes.decode('latin-1') 94 | else: 95 | auth = authbytes 96 | 97 | try: 98 | username, api_key = auth.split(':', 1) 99 | except ValueError: # not enough values to unpack 100 | return None 101 | return username, api_key 102 | -------------------------------------------------------------------------------- /docs/source/field_processors.rst: -------------------------------------------------------------------------------- 1 | Field Processors 2 | ================ 3 | 4 | Nefertari allows to define functions that accept field data and return modified field value, may perform validation or perform other actions related to field. 5 | 6 | These functions are called "field processors". They are set up per-field and are called when request comes into application that modifies the field for which processor is set up (when the field is present in the request JSON body). 7 | 8 | 9 | Setup 10 | ----- 11 | 12 | ``nefertari.events.add_field_processors`` is used to connect processors to fields. This function is accessible through Pyramid Configurator instance. Processors are called in the order in which they are defined. Each processor must return the processed value which is used as input for the successive processor (if such processor exists). ``nefertari.events.add_field_processors`` expects the following parameters: 13 | 14 | **processors** 15 | Sequence of processor functions 16 | 17 | **model** 18 | Model class for field if which processors are registered 19 | 20 | **field** 21 | Field name for which processors are registered 22 | 23 | 24 | Keyword Arguments 25 | ----------------- 26 | 27 | **new_value** 28 | New value of of field 29 | 30 | **instance** 31 | Instance affected by request. Is None when set of items is updated in bulk and when item is created. ``event.response`` may be used to access newly created object, if object is returned by view method. 32 | 33 | **field** 34 | Instance of nefertari.utils.data.FieldData instance containing data of changed field. 35 | 36 | **request** 37 | Current Pyramid Request instance 38 | 39 | **model** 40 | Model class affected by request 41 | 42 | **event** 43 | Underlying event object. Should be used to edit other fields of instance using ``event.set_field_value(field_name, value)`` 44 | 45 | 46 | Example 47 | ------- 48 | 49 | We will use the following example to demonstrate how to connect fields to processors. This processor lowercases values that are passed to it. 50 | 51 | .. code-block:: python 52 | 53 | # processors.py 54 | def lowercase(**kwargs): 55 | return kwargs['new_value'].lower() 56 | 57 | 58 | .. code-block:: python 59 | 60 | # models.py 61 | from nefertari import engine 62 | 63 | 64 | class Item(engine.BaseDocument): 65 | __tablename__ = 'stories' 66 | id = engine.IdField(primary_key=True) 67 | name = engine.StringField(required=True) 68 | 69 | 70 | We want to make sure ``Item.name`` is always lowercase, we can connect ``lowercase`` to ``Item.name`` field using ``nefertari.events.add_field_processors`` like this: 71 | 72 | .. code-block:: python 73 | 74 | # __init__.py 75 | from .models import Item 76 | from .processors import lowercase 77 | 78 | 79 | # Get access to Pyramid configurator 80 | ... 81 | 82 | config.add_field_processors([lowercase], model=Item, field='name') 83 | 84 | 85 | ``lowercase`` processor will be called each time application gets a request that passes ``Item.name`` 86 | 87 | You can use the ``event.set_field_value`` helper method to edit other fields from within a processor. E.g. assuming we had the fields ``due_date`` and ``days_left`` and we connected the processor defined below to the field ``due_date``, we can update ``days_left`` from within that same processor: 88 | 89 | .. code-block:: python 90 | 91 | from .helpers import parse_data 92 | from datetime import datetime 93 | 94 | 95 | def calculate_days_left(**kwargs): 96 | parsed_date = parse_data(kwargs['new_value']) 97 | days_left = (parsed_date-datetime.now()).days 98 | event = kwargs['event'] 99 | event.set_field_value('days_left', days_left) 100 | return kwargs['new_value'] 101 | 102 | 103 | Note: if a field changed via ``event.set_field_value`` is not affected by request, it will be added to ``event.fields`` which will make any field processors which are connected to this field to be triggered, if they are run after this method call (connected to events after handler that performs method call). 104 | 105 | E.g. if in addition to the above ``calculate_days_left`` processor we had another processor for the ``days_left`` field, ``calculate_days_left`` will make the ``days_left`` processor run because ``event.set_field_value`` is called from within ``calculate_days_left`` field and therefor ``days_left`` is considered "updated/changed". 106 | 107 | 108 | API 109 | --- 110 | 111 | .. autoclass:: nefertari.events.FieldIsChanged 112 | :members: 113 | :private-members: 114 | 115 | .. autofunction:: nefertari.events.add_field_processors 116 | -------------------------------------------------------------------------------- /nefertari/scripts/es.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | import sys 3 | import logging 4 | 5 | from pyramid.paster import bootstrap 6 | from pyramid.config import Configurator 7 | from six.moves import urllib 8 | 9 | from nefertari.utils import dictset, split_strip, to_dicts 10 | from nefertari.elasticsearch import ES 11 | from nefertari import engine 12 | 13 | 14 | def main(argv=sys.argv, quiet=False): 15 | log = logging.getLogger() 16 | log.setLevel(logging.WARNING) 17 | ch = logging.StreamHandler() 18 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(message)s') 19 | ch.setFormatter(formatter) 20 | log.addHandler(ch) 21 | 22 | command = ESCommand(argv, log) 23 | return command.run() 24 | 25 | 26 | class ESCommand(object): 27 | 28 | bootstrap = (bootstrap,) 29 | stdout = sys.stdout 30 | usage = '%prog config_uri threshold: 27 | log.warning(msg) 28 | else: 29 | log.debug(msg) 30 | 31 | return timing 32 | 33 | 34 | def get_tunneling(handler, registry): 35 | """ Allows all methods to be tunneled via GET for dev/debuging 36 | purposes. 37 | """ 38 | log.info('get_tunneling enabled') 39 | 40 | def get_tunneling(request): 41 | if request.method == 'GET': 42 | method = request.GET.pop('_m', 'GET') 43 | request.method = method 44 | 45 | if method in ['POST', 'PUT', 'PATCH']: 46 | get_params = request.GET.mixed() 47 | valid_params = drop_reserved_params(get_params) 48 | request.body = six.b(json.dumps(valid_params)) 49 | request.content_type = 'application/json' 50 | request._tunneled_get = True 51 | 52 | return handler(request) 53 | 54 | return get_tunneling 55 | 56 | 57 | def cors(handler, registry): 58 | log.info('cors_tunneling enabled') 59 | 60 | allow_origins_setting = registry.settings.get( 61 | 'cors.allow_origins', '').strip() 62 | 63 | allow_origins = [ 64 | each.strip() for each in allow_origins_setting.split(',')] 65 | allow_credentials = registry.settings.get('cors.allow_credentials', None) 66 | 67 | def cors(request): 68 | origin = request.headers.get('Origin') or request.host_url 69 | response = handler(request) 70 | 71 | if origin in allow_origins or '*' in allow_origins: 72 | response.headerlist.append(('Access-Control-Allow-Origin', origin)) 73 | 74 | if allow_credentials is not None: 75 | response.headerlist.append( 76 | ('Access-Control-Allow-Credentials', allow_credentials)) 77 | 78 | return response 79 | 80 | if not allow_origins_setting: 81 | log.warning('cors.allow_origins is not set') 82 | else: 83 | log.info('Allow Origins = %s ' % allow_origins) 84 | 85 | if allow_credentials is None: 86 | log.warning('cors.allow_credentials is not set') 87 | 88 | elif asbool(allow_credentials) and allow_origins_setting == '*': 89 | raise Exception('Not allowed Access-Control-Allow-Credentials ' 90 | 'to set to TRUE if origin is *') 91 | else: 92 | log.info('Access-Control-Allow-Credentials = %s ' % allow_credentials) 93 | 94 | return cors 95 | 96 | 97 | def cache_control(handler, registry): 98 | log.info('cache_control enabled') 99 | 100 | def cache_control(request): 101 | response = handler(request) 102 | 103 | # change only if the header cache-control is missing 104 | add_header = True 105 | for header in response.headerlist: 106 | if 'Cache-Control' in header: 107 | add_header = False 108 | if add_header: 109 | response.cache_expires(0) 110 | 111 | return response 112 | 113 | return cache_control 114 | 115 | 116 | def ssl(handler, registry): 117 | log.info('ssl enabled') 118 | 119 | def ssl(request): 120 | scheme = request.environ.get('HTTP_X_URL_SCHEME') \ 121 | or request.environ.get('HTTP_X_FORWARDED_PROTO') 122 | 123 | if scheme: 124 | scheme = scheme.lower() 125 | log.debug('setting url_scheme to %s', scheme) 126 | request.scheme = request.environ['wsgi.url_scheme'] = scheme 127 | 128 | return handler(request) 129 | 130 | return ssl 131 | 132 | 133 | from pyramid.events import ContextFound 134 | 135 | 136 | def enable_selfalias(config, id_name): 137 | """ 138 | This allows replacing id_name with "self". 139 | e.g. /users/joe/account == /users/self/account if joe is in the session 140 | as an authorized user 141 | """ 142 | 143 | def context_found_subscriber(event): 144 | request = event.request 145 | user = getattr(request, 'user', None) 146 | if (request.matchdict and 147 | request.matchdict.get(id_name, None) == 'self' and 148 | user): 149 | request.matchdict[id_name] = user.username 150 | 151 | config.add_subscriber(context_found_subscriber, ContextFound) 152 | -------------------------------------------------------------------------------- /docs/source/models.rst: -------------------------------------------------------------------------------- 1 | Configuring Models 2 | ================== 3 | 4 | .. code-block:: python 5 | 6 | from datetime import datetime 7 | from nefertari import engine as eng 8 | from nefertari.engine import BaseDocument 9 | 10 | 11 | class Story(BaseDocument): 12 | __tablename__ = 'stories' 13 | 14 | _auth_fields = [ 15 | 'id', 'updated_at', 'created_at', 'start_date', 16 | 'due_date', 'name', 'description'] 17 | _public_fields = ['id', 'start_date', 'due_date', 'name'] 18 | 19 | id = eng.IdField(primary_key=True) 20 | updated_at = eng.DateTimeField(onupdate=datetime.utcnow) 21 | created_at = eng.DateTimeField(default=datetime.utcnow) 22 | 23 | start_date = eng.DateTimeField(default=datetime.utcnow) 24 | due_date = eng.DateTimeField() 25 | 26 | name = eng.StringField(required=True) 27 | description = eng.TextField() 28 | 29 | 30 | Database Backends 31 | ----------------- 32 | 33 | Nefertari implements database engines on top of two different ORMs: `SQLAlchemy `_ and `MongoEngine `_. These two engines wrap the underlying APIs of each ORM and provide a standardized syntax for using either one, making it easy to switch between them with minimal changes. Each Nefertari engine is maintained in its own repository: 34 | 35 | * `nefertari-sqla github repository `_ 36 | * `nefertari-mongodb github repository `_ 37 | 38 | Nefertari can either use `Elasticsearch `_ (*ESBaseDocument*) or the database engine itself (*BaseDocument*) for reads. 39 | 40 | .. code-block:: python 41 | 42 | from nefertari.engine import ESBaseDocument 43 | 44 | 45 | class Story(ESBaseDocument): 46 | (...) 47 | 48 | or 49 | 50 | .. code-block:: python 51 | 52 | from nefertari.engine import BaseDocument 53 | 54 | 55 | class Story(BaseDocument): 56 | (...) 57 | 58 | You can read more about *ESBaseDocument* and *BaseDocument* in the :any:`Wrapper API ` section below. 59 | 60 | 61 | .. _wrapper-api: 62 | 63 | Wrapper API 64 | ----------- 65 | 66 | Both database engines used by Nefertari implement a "Wrapper API" for developers who use Nefertari in their project. You can read more about either engine's in their respective documentation: 67 | 68 | * `nefertari-sqla documentation `_ 69 | * `nefertari-mongodb documentation `_ 70 | 71 | BaseMixin 72 | Mixin with most of the API of *BaseDocument*. *BaseDocument* subclasses from this mixin. 73 | 74 | BaseDocument 75 | Base for regular models defined in your application. Just subclass it to define your model's fields. Relevant attributes: 76 | 77 | * `__tablename__`: table name (only required by nefertari-sqla) 78 | * `_auth_fields`: String names of fields meant to be displayed to authenticated users. 79 | * `_public_fields`: String names of fields meant to be displayed to non-authenticated users. 80 | * `_hidden_fields`: String names of fields meant to be hidden but editable. 81 | * `_nested_relationships`: String names of relationship fields that should be included in JSON data of an object as full included documents. If relationship field is not present in this list, this field's value in JSON will be an object's ID or list of IDs. 82 | 83 | ESBaseDocument 84 | Subclass of *BaseDocument* instances of which are indexed on create/update/delete. 85 | 86 | ESMetaclass 87 | Document metaclass which is used in *ESBaseDocument* to enable automatic indexation to Elasticsearch of documents. 88 | 89 | get_document_cls(name) 90 | Helper function used to get the class of document by the name of the class. 91 | 92 | JSONEncoder 93 | JSON encoder that should be used to encode output of views. 94 | 95 | ESJSONSerializer 96 | JSON encoder used to encode documents prior indexing them in Elasticsearch. 97 | 98 | relationship_fields 99 | Tuple of classes that represent relationship fields in specific engine. 100 | 101 | is_relationship_field(field, model_cls) 102 | Helper function to determine whether *field* is a relationship field at *model_cls* class. 103 | 104 | relationship_cls(field, model_cls) 105 | Return class which is pointed to by relationship field *field* from model *model_cls*. 106 | 107 | 108 | Field Types 109 | ----------- 110 | 111 | This is the list of all the available field types: 112 | 113 | * BigIntegerField 114 | * BinaryField 115 | * BooleanField 116 | * ChoiceField 117 | * DateField 118 | * DateTimeField 119 | * DecimalField 120 | * DictField 121 | * FloatField 122 | * ForeignKeyField (ignored/not required when using mongodb) 123 | * IdField 124 | * IntegerField 125 | * IntervalField 126 | * ListField 127 | * PickleField 128 | * Relationship 129 | * SmallIntegerField 130 | * StringField 131 | * TextField 132 | * TimeField 133 | * UnicodeField 134 | * UnicodeTextField 135 | -------------------------------------------------------------------------------- /docs/source/making_requests.rst: -------------------------------------------------------------------------------- 1 | Making requests 2 | =============== 3 | 4 | 5 | Query syntax 6 | ------------ 7 | 8 | Query parameters can be used on either GET, PATCH, PUT or DELETE requests. 9 | 10 | =============================== =========== 11 | url parameter description 12 | =============================== =========== 13 | ``_m=`` tunnel any http method using GET, e.g. _m=POST [#]_ 14 | ``_limit=`` limit the returned collection to results 15 | (default: 20, max limit: 100 for unauthenticated users) 16 | ``_sort=`` sort collection by 17 | ``_start=`` start collection from the th resource 18 | ``_page=`` start collection at page (n * _limit) 19 | ``_fields=`` display only specific fields, use ``-`` before field 20 | names to exclude those fields, e.g. ``_fields=-descripton`` 21 | =============================== =========== 22 | 23 | 24 | Query syntax for Elasticsearch 25 | ------------------------------ 26 | 27 | Additional parameters are available when using an Elasticsearch-enabled collection (see **ESBaseDocument** in the :any:`Wrapper API ` section of this documentation). 28 | 29 | ======================================== =========== 30 | url parameter description 31 | ======================================== =========== 32 | ``=`` to filter a collection using full-text search on , ES operators [#]_ can be used, e.g. ``?title=foo AND bar`` 33 | ``=(!)`` to filter a collection using negative search 34 | ``q=`` to filter a collection using full-text search on all fields 35 | ``_search_fields=`` use with ``?q=`` to restrict search to specific fields 36 | ``_refresh_index=true`` to refresh the ES index after performing the operation [#]_ 37 | ``_aggregations.`` to use ES search aggregations, 38 | e.g. ``?_aggregations.my_agg.terms.field=tag`` [#]_ 39 | ======================================== =========== 40 | 41 | 42 | Updating listfields 43 | ------------------- 44 | 45 | Items in listfields can be removed using "-" prefix. 46 | 47 | .. code-block:: sh 48 | 49 | $ curl -XPATCH 'http://localhost:6543/api//' -d '{ 50 | "": [-] 51 | } 52 | ' 53 | 54 | Items can be both added and removed at the same time. 55 | 56 | .. code-block:: sh 57 | 58 | $ curl -XPATCH 'http://localhost:6543/api//' -d '{ 59 | "": [,-] 60 | } 61 | ' 62 | 63 | Listfields can be emptied by setting their value to "" or null. 64 | 65 | .. code-block:: sh 66 | 67 | $ curl -XPATCH 'http://localhost:6543/api//' -d '{ 68 | "": "" 69 | } 70 | ' 71 | 72 | 73 | Updating collections 74 | -------------------- 75 | 76 | If update_many() is defined in your view, you will be able to update a single field across an entire collection or a filtered collection. E.g. 77 | 78 | .. code-block:: sh 79 | 80 | $ curl -XPATCH 'http://localhost:6543/api/?q=' -d '{ 81 | "": "" 82 | } 83 | ' 84 | 85 | 86 | Deleting collections 87 | -------------------- 88 | 89 | Similarly, if delete_many() is defined, you will be able to delete entire collections or filtered collections. E.g. 90 | 91 | .. code-block:: sh 92 | 93 | $ curl -XDELETE 'http://localhost:6543/api/?_missing_=' 94 | 95 | 96 | .. [#] Set ``enable_get_tunneling = true`` in your .ini file to enable this feature. To update listfields and dictfields, you can use the following syntax: ``_m=PATCH&.&.=`` 97 | .. [#] The full syntax of Elasticsearch querying is beyond the scope of this documentation. You can read more on the `Elasticsearch Query String Query documentation `_ to do things like fuzzy search: ``?name=fuzzy~`` or date range search: ``?date=[2015-01-01 TO *]`` 98 | .. [#] Set ``elasticsearch.enable_refresh_query = true`` in your .ini file to enable this feature. This parameter only works with POST, PATCH, PUT and DELETE methods. Read more on `Elasticsearch Bulk API documentation `_. 99 | .. [#] Set ``elasticsearch.enable_aggregations = true`` in your .ini file to enable this feature. You can also use the short name `_aggs`. Read more on `Elasticsearch Aggregations `_. 100 | -------------------------------------------------------------------------------- /tests/test_authentication/test_policies.py: -------------------------------------------------------------------------------- 1 | from mock import Mock, patch 2 | 3 | from nefertari import authentication as auth 4 | from .fixtures import engine_mock 5 | 6 | 7 | @patch('nefertari.authentication.policies.create_apikey_model') 8 | class TestApiKeyAuthenticationPolicy(object): 9 | 10 | def test_init(self, mock_apikey, engine_mock): 11 | user_model = Mock() 12 | policy = auth.policies.ApiKeyAuthenticationPolicy( 13 | user_model=user_model, check='foo', 14 | credentials_callback='bar') 15 | assert not engine_mock.get_document_cls.called 16 | mock_apikey.assert_called_once_with(user_model) 17 | assert policy.check == 'foo' 18 | assert policy.credentials_callback == 'bar' 19 | 20 | def test_init_string_user_model(self, mock_apikey, engine_mock): 21 | policy = auth.policies.ApiKeyAuthenticationPolicy( 22 | user_model='User1', check='foo', 23 | credentials_callback='bar') 24 | engine_mock.get_document_cls.assert_called_once_with('User1') 25 | mock_apikey.assert_called_once_with(engine_mock.get_document_cls()) 26 | assert policy.check == 'foo' 27 | assert policy.credentials_callback == 'bar' 28 | 29 | def test_remember(self, mock_apikey, engine_mock): 30 | policy = auth.policies.ApiKeyAuthenticationPolicy( 31 | user_model='User1', check='foo', 32 | credentials_callback='bar') 33 | policy.credentials_callback = lambda uname, req: 'token' 34 | headers = policy.remember(request=None, username='user1') 35 | assert headers == [('WWW-Authenticate', 'ApiKey user1:token')] 36 | 37 | def test_forget(self, mock_apikey, engine_mock): 38 | policy = auth.policies.ApiKeyAuthenticationPolicy( 39 | user_model='User1', check='foo', 40 | credentials_callback='bar') 41 | policy.realm = 'Foo' 42 | headers = policy.forget(request=None) 43 | assert headers == [('WWW-Authenticate', 'ApiKey realm="Foo"')] 44 | 45 | def test_unauthenticated_userid(self, mock_apikey, engine_mock): 46 | policy = auth.policies.ApiKeyAuthenticationPolicy( 47 | user_model='User1', check='foo', 48 | credentials_callback='bar') 49 | policy._get_credentials = Mock() 50 | policy._get_credentials.return_value = ('user1', 'token') 51 | val = policy.unauthenticated_userid(request=1) 52 | policy._get_credentials.assert_called_once_with(1) 53 | assert val == 'user1' 54 | 55 | def test_callback_no_creds(self, mock_apikey, engine_mock): 56 | policy = auth.policies.ApiKeyAuthenticationPolicy( 57 | user_model='User1', check='foo', 58 | credentials_callback='bar') 59 | policy._get_credentials = Mock(return_value=None) 60 | policy.check = Mock() 61 | policy.callback('user1', 1) 62 | policy._get_credentials.assert_called_once_with(1) 63 | assert not policy.check.called 64 | 65 | def test_callback(self, mock_apikey, engine_mock): 66 | policy = auth.policies.ApiKeyAuthenticationPolicy( 67 | user_model='User1', check='foo', 68 | credentials_callback='bar') 69 | policy._get_credentials = Mock(return_value=('user1', 'token')) 70 | policy.check = Mock() 71 | policy.callback('user1', 1) 72 | policy._get_credentials.assert_called_once_with(1) 73 | policy.check.assert_called_once_with('user1', 'token', 1) 74 | 75 | def test_get_credentials_no_header(self, mock_apikey, engine_mock): 76 | policy = auth.policies.ApiKeyAuthenticationPolicy( 77 | user_model='User1', check='foo', 78 | credentials_callback='bar') 79 | request = Mock(headers={}) 80 | assert policy._get_credentials(request) is None 81 | 82 | def test_get_credentials_wrong_header(self, mock_apikey, engine_mock): 83 | policy = auth.policies.ApiKeyAuthenticationPolicy( 84 | user_model='User1', check='foo', 85 | credentials_callback='bar') 86 | request = Mock(headers={'Authorization': 'foo'}) 87 | assert policy._get_credentials(request) is None 88 | 89 | def test_get_credentials_not_apikey_header(self, mock_apikey, engine_mock): 90 | policy = auth.policies.ApiKeyAuthenticationPolicy( 91 | user_model='User1', check='foo', 92 | credentials_callback='bar') 93 | request = Mock(headers={'Authorization': 'foo bar'}) 94 | assert policy._get_credentials(request) is None 95 | 96 | def test_get_credentials_not_full_token(self, mock_apikey, engine_mock): 97 | policy = auth.policies.ApiKeyAuthenticationPolicy( 98 | user_model='User1', check='foo', 99 | credentials_callback='bar') 100 | request = Mock(headers={'Authorization': 'ApiKey user1'}) 101 | assert policy._get_credentials(request) is None 102 | 103 | def test_get_credentials(self, mock_apikey, engine_mock): 104 | policy = auth.policies.ApiKeyAuthenticationPolicy( 105 | user_model='User1', check='foo', 106 | credentials_callback='bar') 107 | request = Mock(headers={'Authorization': 'ApiKey user1:token'}) 108 | assert policy._get_credentials(request) == ('user1', 'token') 109 | -------------------------------------------------------------------------------- /tests/test_utils/test_data.py: -------------------------------------------------------------------------------- 1 | from mock import Mock 2 | 3 | from nefertari.utils import data as dutils 4 | 5 | 6 | class DummyModel(dict): 7 | def to_dict(self, *args, **kwargs): 8 | return self 9 | 10 | 11 | class TestDataUtils(object): 12 | 13 | def test_data_proxy_not_model(self): 14 | proxy = dutils.DataProxy({'foo': 'bar'}) 15 | data = proxy.to_dict() 16 | assert data == {'_type': 'DataProxy', 'foo': 'bar'} 17 | 18 | def test_data_proxy_not_model_keys(self): 19 | proxy = dutils.DataProxy({'foo': 'bar', 'id': 1}) 20 | data = proxy.to_dict(_keys=['foo']) 21 | assert data == {'_type': 'DataProxy', 'foo': 'bar'} 22 | 23 | def test_data_proxy_model(self): 24 | obj = DummyModel({'foo1': 'bar1'}) 25 | proxy = dutils.DataProxy({'foo': obj}) 26 | data = proxy.to_dict() 27 | assert data == {'_type': 'DataProxy', 'foo': {'foo1': 'bar1'}} 28 | 29 | def test_data_proxy_model_keys(self): 30 | obj = DummyModel({'foo1': 'bar1'}) 31 | proxy = dutils.DataProxy({'foo': obj, 'id': 1}) 32 | data = proxy.to_dict(_keys=['foo']) 33 | assert data == {'_type': 'DataProxy', 'foo': {'foo1': 'bar1'}} 34 | 35 | def test_data_proxy_model_no_depth(self): 36 | obj = DummyModel({'foo1': 'bar1'}) 37 | proxy = dutils.DataProxy({'foo': obj}) 38 | data = proxy.to_dict(_depth=0) 39 | assert data == {'_type': 'DataProxy', 'foo': obj} 40 | 41 | def test_data_proxy_model_sequence(self): 42 | obj = DummyModel({'foo1': 'bar1'}) 43 | proxy = dutils.DataProxy({'foo': [obj]}) 44 | data = proxy.to_dict() 45 | assert data == {'_type': 'DataProxy', 'foo': [{'foo1': 'bar1'}]} 46 | 47 | def test_dict2obj_regular_value(self): 48 | obj = dutils.dict2obj({'_type': 'Foo', 'foo': 'bar', 'baz': 1}) 49 | assert isinstance(obj, dutils.DataProxy) 50 | assert obj.foo == 'bar' 51 | assert obj.baz == 1 52 | 53 | def test_dict2obj_dict_value(self): 54 | obj = dutils.dict2obj({'_type': 'Foo', 'foo': {'baz': 1}}) 55 | assert isinstance(obj, dutils.DataProxy) 56 | assert isinstance(obj.foo, dutils.DataProxy) 57 | assert obj.foo.baz == 1 58 | 59 | def test_dict2obj_list_value(self): 60 | obj = dutils.dict2obj({'_type': 'Foo', 'foo': [{'baz': 1}]}) 61 | assert isinstance(obj, dutils.DataProxy) 62 | assert isinstance(obj.foo, list) 63 | assert len(obj.foo) == 1 64 | assert isinstance(obj.foo[0], dutils.DataProxy) 65 | assert obj.foo[0].baz == 1 66 | 67 | def test_to_objs(self): 68 | collection = dutils.to_objs([{'foo': 'bar', '_type': 'Foo'}]) 69 | assert len(collection) == 1 70 | assert isinstance(collection[0], dutils.DataProxy) 71 | assert collection[0].foo == 'bar' 72 | 73 | def test_to_dicts_regular_case(self): 74 | collection = [DummyModel({'foo': 'bar'})] 75 | dicts = dutils.to_dicts(collection) 76 | assert dicts == [{'foo': 'bar'}] 77 | 78 | def test_to_dicts_with_key(self): 79 | collection = [DummyModel({'foo': 'bar', 'id': '1'})] 80 | dicts = dutils.to_dicts(collection, key=lambda d: {'super': d['foo']}) 81 | assert dicts == [{'super': 'bar'}] 82 | 83 | def test_to_dicts_attr_error(self): 84 | obj = DummyModel({'foo': 'bar'}) 85 | dicts = dutils.to_dicts([obj, {'a': 'b'}]) 86 | assert dicts == [obj, {'a': 'b'}] 87 | 88 | def test_to_dicts_type_error(self): 89 | def key(d): 90 | raise TypeError() 91 | obj = DummyModel({'foo': 'bar'}) 92 | dicts = dutils.to_dicts([obj], key=key) 93 | assert dicts == [obj] 94 | 95 | def test_obj2dict_dict(self): 96 | assert dutils.obj2dict({'foo': 'bar'}) == {'foo': 'bar'} 97 | 98 | def test_obj2dict_list(self): 99 | assert dutils.obj2dict([{'foo': 'bar'}]) == [{'foo': 'bar'}] 100 | 101 | def test_obj2dict_object(self): 102 | class A(object): 103 | pass 104 | obj = A() 105 | obj.foo = 'bar' 106 | assert dutils.obj2dict(obj) == {'foo': 'bar'} 107 | 108 | def test_obj2dict_object_classkey(self): 109 | class A(object): 110 | pass 111 | obj = A() 112 | obj.foo = 'bar' 113 | assert dutils.obj2dict(obj, classkey='kls') == { 114 | 'foo': 'bar', 'kls': 'A'} 115 | 116 | def test_obj2dict_simple_types(self): 117 | assert dutils.obj2dict(1) == 1 118 | assert dutils.obj2dict('foo') == 'foo' 119 | assert dutils.obj2dict(None) is None 120 | 121 | 122 | class TestFieldData(object): 123 | 124 | def test_init(self): 125 | obj = dutils.FieldData(name='foo', new_value=1, params={}) 126 | assert obj.name == 'foo' 127 | assert obj.new_value == 1 128 | assert obj.params == {} 129 | 130 | def test_repr(self): 131 | obj = dutils.FieldData(name='foo', new_value=1, params={}) 132 | assert str(obj) == '' 133 | 134 | def test_from_dict_model_provided(self): 135 | model = Mock() 136 | model.get_field_params.return_value = {'foo': 1} 137 | data = {'username': 'admin'} 138 | result = dutils.FieldData.from_dict(data, model) 139 | assert list(result.keys()) == ['username'] 140 | field = result['username'] 141 | assert isinstance(field, dutils.FieldData) 142 | assert field.name == 'username' 143 | assert field.new_value == 'admin' 144 | assert field.params == {'foo': 1} 145 | model.get_field_params.assert_called_once_with('username') 146 | -------------------------------------------------------------------------------- /docs/source/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | * :release:`0.7.0 <2016-05-17>` 5 | * :bug:`121 major` Fixed issue with nested resources referencing parents 6 | * :bug:`128 major` Build ES params when body provided 7 | * :support:`130` Added support for Elasticsearch 2.x 8 | * :support:`-` Added support for Pyramid 1.6.x 9 | * :support:`-` Scaffold defaults to Pyramid 1.6.1 10 | * :feature:`-` Added ability to edit responses within 'after' event handlers 11 | 12 | * :release:`0.6.1 <2015-11-18>` 13 | * :bug:`-` Added 'event.instance' to 'event' object to access newly created object (if object is returned by view method) 14 | * :bug:`-` Fixed a bug with GET '/auth/logout' 15 | * :bug:`-` 'request.user' is now set to None when using 'auth = False' 16 | 17 | * :release:`0.6.0 <2015-10-07>` 18 | * :feature:`-` Event system is now crud-based as opposed to db-based 19 | * :feature:`-` Refactored field processors to use the new event system 20 | * :feature:`-` Removed unnecessary extra '__confirmation' parameter from PATCH/PUT/DELETE collection requests 21 | * :feature:`-` Nested relationships are now indexed in bulk in Elasticsearch 22 | * :feature:`-` Added '_hidden_fields' model attribute to hide fields while remaining editable (e.g. password) 23 | * :bug:`- major` Fixed a bug causing polymorchic collections to always return 403 24 | * :bug:`- major` Fixed nested relationships not respecting '_auth_fields' 25 | * :support:`-` Added support for `'nefertari-guards' `_ 26 | 27 | * :release:`0.5.1 <2015-09-02>` 28 | * :bug:`-` Fixed '_self' param for ``/api/users/self`` convience route 29 | * :bug:`-` Fixed a bug when using reserved query params with GET tunneling 30 | * :bug:`-` Fixed an error preventing RelationshipFields' backrefs to be set as _nested_relationships 31 | * :bug:`-` Fixed a bug allowing to update hidden fields 32 | * :bug:`-` Simplified ACLs (refactoring) 33 | 34 | * :release:`0.5.0 <2015-08-19>` 35 | * :feature:`-` Renamed field 'self' to '_self' 36 | * :feature:`-` Refactored authentication 37 | * :feature:`-` Renamed setting `debug` to `enable_get_tunneling` 38 | * :feature:`-` Added the ability to apply processors on 'Relationship' fields and their backrefs 39 | * :feature:`-` Model's save()/update()/delete()/_delete_many()/_update_many() methods now require self.request to be passed for '_refresh_index' parameter to work 40 | * :feature:`-` Routes can now have the same member/collection name. E.g. root.add('staff', 'staff', ...) 41 | * :bug:`- major` Fixed sorting by 'id' when two ES-based models have two different 'id' field types 42 | * :bug:`- major` Removed unused 'id' field from 'AuthUserMixin' 43 | * :bug:`- major` Fixed bug with full-text search ('?q=') when used in combination with field search ('&=') 44 | * :bug:`- major` Fixed 40x error responses returning html, now all responses are json-formatted 45 | * :bug:`- major` Fixed formatting error when using `_fields` query parameter 46 | * :bug:`- major` Fixed duplicate records when querying ES aggregations by '_type' 47 | * :bug:`- major` Fixed 400 error returned when querying resources with id in another format than the id field used in URL schema, e.g. ``/api//``, it now returns 404 48 | * :bug:`- major` Fixed `_count` querying not respecting ``public_max_limit`` .ini setting 49 | * :bug:`- major` Fixed error response when aggregating hidden fields with ``auth = true``, it now returns 403 50 | 51 | * :release:`0.4.1 <2015-07-07>` 52 | * :bug:`-` Fixed a bug when setting ``cors.allow_origins = *`` 53 | * :bug:`-` Fixed errors in http methods HEAD/OPTIONS response 54 | * :bug:`-` Fixed response of http methods POST/PATCH/PUT not returning created/updated objects 55 | * :support:`- backported` Added support for Elasticsearch polymorphic collections accessible at ``/api/,`` 56 | 57 | * :release:`0.4.0 <2015-06-14>` 58 | * :support:`-` Added python3 support 59 | * :feature:`-` Added ES aggregations 60 | * :feature:`-` Reworked ES bulk queries to use 'elasticsearch.helpers.bulk' 61 | * :feature:`-` Added ability to empty listfields by setting them to "" or null 62 | 63 | * :release:`0.3.4 <2015-06-09>` 64 | * :bug:`-` Fixed bug whereby `_count` would throw exception when authentication was enabled 65 | 66 | * :release:`0.3.3 <2015-06-05>` 67 | * :bug:`-` Fixed bug with posting multiple new relations at the same time 68 | 69 | * :release:`0.3.2 <2015-06-03>` 70 | * :bug:`-` Fixed bug with Elasticsearch indexing of nested relationships 71 | * :bug:`-` Fixed race condition in Elasticsearch indexing by adding the optional '_refresh_index' query parameter 72 | 73 | * :release:`0.3.1 <2015-05-27>` 74 | * :bug:`-` Fixed PUT to replace all fields and PATCH to update some 75 | * :bug:`-` Fixed posting to singular resources e.g. ``/api/users//profile`` 76 | * :bug:`-` Fixed ES mapping error when values of field were all null 77 | 78 | * :release:`0.3.0 <2015-05-18>` 79 | * :support:`-` Step-by-step 'Getting started' guide 80 | * :bug:`- major` Fixed several issues related to Elasticsearch indexing 81 | * :support:`-` Increased test coverave 82 | * :feature:`-` Added ability to PATCH/DELETE collections 83 | * :feature:`-` Implemented API output control by field (apply_privacy wrapper) 84 | 85 | * :release:`0.2.1 <2015-04-21>` 86 | * :bug:`-` Fixed URL parsing for DictField and ListField values with _m=VERB options 87 | 88 | * :release:`0.2.0 <2015-04-07>` 89 | * :feature:`-` Added script to index Elasticsearch models 90 | * :feature:`-` Started adding tests 91 | * :support:`-` Listing on PyPI 92 | * :support:`-` Improved docs 93 | 94 | * :release:`0.1.1 <2015-04-01>` 95 | * :support:`-` Initial release after two years of development as 'Presto'. Now with database engines! Originally extracted and generalized from the Brandicted API which only used MongoDB. 96 | -------------------------------------------------------------------------------- /tests/test_json_httpexceptions.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | import six 5 | from mock import Mock, patch 6 | 7 | from nefertari import json_httpexceptions as jsonex 8 | from nefertari.renderers import _JSONEncoder 9 | 10 | 11 | class TestJSONHTTPExceptionsModule(object): 12 | 13 | def test_includeme(self): 14 | config = Mock() 15 | jsonex.includeme(config) 16 | config.add_view.assert_called_once_with( 17 | view=jsonex.httperrors, 18 | context=jsonex.http_exc.HTTPError) 19 | 20 | @patch.object(jsonex, 'traceback') 21 | def test_add_stack(self, mock_trace): 22 | mock_trace.format_stack.return_value = ['foo', 'bar'] 23 | assert jsonex.add_stack() == 'foobar' 24 | 25 | def test_create_json_response(self): 26 | request = Mock( 27 | url='http://example.com', 28 | client_addr='127.0.0.1', 29 | remote_addr='127.0.0.2') 30 | obj = Mock( 31 | status_int=401, 32 | location='http://example.com/api') 33 | obj2 = jsonex.create_json_response( 34 | obj, request, encoder=_JSONEncoder, 35 | status_code=402, explanation='success', 36 | message='foo', title='bar') 37 | assert obj2.content_type == 'application/json' 38 | assert isinstance(obj2.body, six.binary_type) 39 | body = json.loads(obj2.body.decode('utf-8')) 40 | assert sorted(body.keys()) == [ 41 | '_pk', 'client_addr', 'explanation', 'message', 'remote_addr', 42 | 'request_url', 'status_code', 'timestamp', 'title' 43 | ] 44 | assert body['remote_addr'] == '127.0.0.2' 45 | assert body['client_addr'] == '127.0.0.1' 46 | assert body['status_code'] == 402 47 | assert body['explanation'] == 'success' 48 | assert body['title'] == 'bar' 49 | assert body['message'] == 'foo' 50 | assert body['_pk'] == 'api' 51 | assert body['request_url'] == 'http://example.com' 52 | 53 | @patch.object(jsonex, 'add_stack') 54 | def test_create_json_response_obj_properties(self, mock_stack): 55 | mock_stack.return_value = 'foo' 56 | obj = Mock( 57 | status_int=401, 58 | location='http://example.com/api', 59 | status_code=402, explanation='success', 60 | message='foo', title='bar') 61 | obj2 = jsonex.create_json_response( 62 | obj, None, encoder=_JSONEncoder) 63 | body = json.loads(obj2.body.decode('utf-8')) 64 | assert body['status_code'] == 402 65 | assert body['explanation'] == 'success' 66 | assert body['title'] == 'bar' 67 | assert body['message'] == 'foo' 68 | assert body['_pk'] == 'api' 69 | 70 | @patch.object(jsonex, 'add_stack') 71 | def test_create_json_response_stack_calls(self, mock_stack): 72 | mock_stack.return_value = 'foo' 73 | obj = Mock(status_int=401, location='http://example.com/api') 74 | jsonex.create_json_response(obj, None, encoder=_JSONEncoder) 75 | assert mock_stack.call_count == 0 76 | 77 | obj = Mock(status_int=500, location='http://example.com/api') 78 | jsonex.create_json_response(obj, None, encoder=_JSONEncoder) 79 | mock_stack.assert_called_with() 80 | assert mock_stack.call_count == 1 81 | 82 | obj = Mock(status_int=401, location='http://example.com/api') 83 | jsonex.create_json_response( 84 | obj, None, encoder=_JSONEncoder, show_stack=True) 85 | mock_stack.assert_called_with() 86 | assert mock_stack.call_count == 2 87 | 88 | obj = Mock(status_int=401, location='http://example.com/api') 89 | jsonex.create_json_response( 90 | obj, None, encoder=_JSONEncoder, log_it=True) 91 | mock_stack.assert_called_with() 92 | assert mock_stack.call_count == 3 93 | 94 | def test_create_json_response_with_body(self): 95 | obj = Mock( 96 | status_int=401, 97 | location='http://example.com/api') 98 | obj2 = jsonex.create_json_response( 99 | obj, None, encoder=_JSONEncoder, 100 | status_code=402, explanation='success', 101 | message='foo', title='bar', body={'zoo': 'zoo'}) 102 | assert obj2.content_type == 'application/json' 103 | assert isinstance(obj2.body, six.binary_type) 104 | body = json.loads(obj2.body.decode('utf-8')) 105 | assert body == {'zoo': 'zoo'} 106 | 107 | def test_exception_response(self): 108 | jsonex.STATUS_MAP[12345] = lambda x: x + 3 109 | assert jsonex.exception_response(12345, x=1) == 4 110 | with pytest.raises(KeyError): 111 | jsonex.exception_response(3123123123123123) 112 | jsonex.STATUS_MAP.pop(12345, None) 113 | 114 | def test_status_map(self): 115 | codes = [ 116 | 200, 201, 202, 203, 204, 205, 206, 117 | 300, 301, 302, 303, 304, 305, 307, 118 | 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 119 | 411, 412, 413, 414, 415, 416, 417, 422, 423, 424, 120 | 500, 501, 502, 503, 504, 505, 507 121 | ] 122 | for code in codes: 123 | assert code in jsonex.STATUS_MAP 124 | for code_exc in jsonex.STATUS_MAP.values(): 125 | assert hasattr(jsonex, code_exc.__name__) 126 | 127 | @patch.object(jsonex, 'create_json_response') 128 | def test_httperrors(self, mock_create): 129 | jsonex.httperrors({'foo': 'bar'}, 1) 130 | mock_create.assert_called_once_with({'foo': 'bar'}, request=1) 131 | 132 | @patch.object(jsonex, 'create_json_response') 133 | def test_jhttpcreated(self, mock_create): 134 | resp = jsonex.JHTTPCreated( 135 | resource={'foo': 'bar'}, 136 | location='http://example.com/1', 137 | encoder=1) 138 | mock_create.assert_called_once_with( 139 | obj=resp, resource={'foo': 'bar', '_self': 'http://example.com/1'}, 140 | request=None, encoder=1, body=None) 141 | -------------------------------------------------------------------------------- /nefertari/utils/dictset.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import six 4 | from pyramid.settings import asbool 5 | 6 | from nefertari.utils.utils import process_fields, split_strip 7 | 8 | 9 | class dictset(dict): 10 | def copy(self): 11 | return dictset(super(dictset, self).copy()) 12 | 13 | def subset(self, keys): 14 | only, exclude = process_fields(keys) 15 | 16 | if only and not exclude: 17 | return dictset([[k, v] for k, v in self.items() if k in only]) 18 | 19 | if exclude: 20 | return dictset([[k, v] for k, v in self.items() 21 | if k not in exclude]) 22 | 23 | return dictset() 24 | 25 | def remove(self, keys): 26 | only, _ = process_fields(keys) 27 | return dictset([[k, v] for k, v in self.items() if k not in only]) 28 | 29 | def __getattr__(self, key): 30 | return self[key] 31 | 32 | def __setattr__(self, key, val): 33 | self[key] = val 34 | 35 | def asbool(self, name, default=False, _set=False, pop=False): 36 | val = asbool(self.get(name, default)) 37 | if _set: 38 | self[name] = val 39 | 40 | if pop: 41 | self.pop(name, None) 42 | 43 | return val 44 | 45 | def aslist(self, name, remove_empty=True, default=[], _set=False): 46 | _lst = split_strip(self.get(name, default) or default) 47 | if remove_empty: 48 | _lst = list(filter(bool, _lst)) 49 | 50 | if _set: 51 | self[name] = _lst 52 | return _lst 53 | 54 | def asint(self, name, default=0, _set=False): 55 | val = int(self.get(name, default)) 56 | if _set: 57 | self[name] = val 58 | return val 59 | 60 | def asfloat(self, name, default=0.0, _set=False): 61 | val = float(self.get(name, default)) 62 | if _set: 63 | self[name] = val 64 | return val 65 | 66 | def asdict(self, name, _type=None, _set=False): 67 | """ 68 | Turn this 'a:2,b:blabla,c:True,a:'d' to 69 | {a:[2, 'd'], b:'blabla', c:True} 70 | 71 | """ 72 | 73 | if _type is None: 74 | _type = lambda t: t 75 | 76 | dict_str = self.pop(name, None) 77 | if not dict_str: 78 | return {} 79 | 80 | _dict = {} 81 | for item in split_strip(dict_str): 82 | key, _, val = item.partition(':') 83 | val = _type(val) 84 | if key in _dict: 85 | if isinstance(_dict[key], list): 86 | _dict[key].append(val) 87 | else: 88 | _dict[key] = [_dict[key], val] 89 | else: 90 | _dict[key] = val 91 | 92 | if _set: 93 | self[name] = _dict 94 | 95 | return _dict 96 | 97 | def mget(self, prefix, defaults={}): 98 | if not prefix.endswith('.'): 99 | prefix += '.' 100 | 101 | _dict = dictset(defaults) 102 | for key, val in self.items(): 103 | if key.startswith(prefix): 104 | _k = key.partition(prefix)[-1] 105 | if val: 106 | _dict[_k] = val 107 | return _dict 108 | 109 | def update(self, *args, **kw): 110 | super(dictset, self).update(*args, **kw) 111 | return self 112 | 113 | def process_list_param(self, name, _type=None, default=None, pop=False, 114 | setdefault=None): 115 | if _type is None: 116 | _type = lambda t: t 117 | 118 | _csv = self.get(name, '') 119 | if _csv and isinstance(_csv, six.string_types): 120 | self[name] = [_type(each) for each in split_strip(_csv)] 121 | 122 | if name not in self and setdefault is not None: 123 | self[name] = setdefault 124 | 125 | if pop: 126 | if default is not None: 127 | return self.pop(name, default) 128 | else: 129 | return self.pop(name) 130 | else: 131 | if default is not None: 132 | return self.get(name, default) 133 | else: 134 | return self.get(name) 135 | 136 | def process_bool_param(self, name, default=None): 137 | if name in self: 138 | self[name] = asbool(self[name]) 139 | elif default is not None: 140 | self[name] = default 141 | 142 | return self.get(name, None) 143 | 144 | def pop_bool_param(self, name, default=False): 145 | if name in self: 146 | return asbool(self.pop(name)) 147 | else: 148 | return default 149 | 150 | def process_datetime_param(self, name): 151 | if name in self: 152 | try: 153 | self[name] = datetime.strptime( 154 | self[name], "%Y-%m-%dT%H:%M:%SZ") 155 | except ValueError: 156 | raise ValueError( 157 | "Bad format for '%s' param. Must be ISO 8601, " 158 | "YYYY-MM-DDThh:mm:ssZ" % name) 159 | 160 | return self.get(name, None) 161 | 162 | def process_float_param(self, name, default=None): 163 | if name in self: 164 | try: 165 | self[name] = float(self[name]) 166 | except ValueError: 167 | raise ValueError('%s must be a decimal' % name) 168 | 169 | elif default is not None: 170 | self[name] = default 171 | return self.get(name, None) 172 | 173 | def process_int_param(self, name, default=None): 174 | if name in self: 175 | try: 176 | self[name] = int(self[name]) 177 | except ValueError: 178 | raise ValueError('%s must be an integer' % name) 179 | 180 | elif default is not None: 181 | self[name] = default 182 | return self.get(name, None) 183 | 184 | def process_dict_param(self, name, _type=None, pop=False): 185 | return self.asdict(name, _type, _set=not pop) 186 | 187 | def pop_by_values(self, val): 188 | keys_to_pop = [] 189 | for k, v in self.items(): 190 | if v == val: 191 | keys_to_pop.append(k) 192 | for key in keys_to_pop: 193 | self.pop(key) 194 | return self 195 | -------------------------------------------------------------------------------- /nefertari/utils/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import json 4 | from contextlib import contextmanager 5 | 6 | import six 7 | from pyramid.config import Configurator 8 | 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | def get_json_encoder(): 14 | try: 15 | from nefertari import engine 16 | return engine.JSONEncoder 17 | except AttributeError: 18 | from nefertari.renderers import _JSONEncoder 19 | return _JSONEncoder 20 | 21 | 22 | def json_dumps(body, encoder=None): 23 | if encoder is None: 24 | encoder = get_json_encoder() 25 | return json.dumps(body, cls=encoder) 26 | 27 | 28 | def split_strip(_str, on=','): 29 | lst = _str if isinstance(_str, list) else _str.split(on) 30 | return list(filter(bool, [e.strip() for e in lst])) 31 | 32 | 33 | def process_limit(start, page, limit): 34 | try: 35 | limit = int(limit) 36 | 37 | if start is not None and page is not None: 38 | raise ValueError('Can not specify _start and _page at the ' 39 | 'same time') 40 | 41 | if start is not None: 42 | start = int(start) 43 | elif page is not None: 44 | start = int(page)*limit 45 | else: 46 | start = 0 47 | 48 | if limit < 0 or start < 0: 49 | raise ValueError('_limit/_page or _limit/_start can not be < 0') 50 | 51 | except (ValueError, TypeError) as e: 52 | raise ValueError(e) 53 | 54 | return start, limit 55 | 56 | 57 | def extend_list(param): 58 | _new = [] 59 | if isinstance(param, (list, set)): 60 | for each in param: 61 | if isinstance(each, six.string_types) and each.find(',') != -1: 62 | _new.extend(split_strip(each)) 63 | else: 64 | _new.append(each) 65 | 66 | elif isinstance(param, six.string_types) and param.find(',') != -1: 67 | _new = split_strip(param) 68 | 69 | return _new 70 | 71 | 72 | def process_fields(_fields): 73 | fields_only = [] 74 | fields_exclude = [] 75 | 76 | if isinstance(_fields, six.string_types): 77 | _fields = split_strip(_fields) 78 | 79 | for field in extend_list(_fields): 80 | field = field.strip() 81 | if not field: 82 | continue 83 | if field[0] == "-": 84 | fields_exclude.append(field[1:]) 85 | else: 86 | fields_only.append(field) 87 | return fields_only, fields_exclude 88 | 89 | 90 | def snake2camel(text): 91 | "turn the snake case to camel case: snake_camel -> SnakeCamel" 92 | return ''.join([a.title() for a in text.split("_")]) 93 | 94 | 95 | def maybe_dotted(module, throw=True): 96 | """ If ``module`` is a dotted string pointing to the module, 97 | imports and returns the module object. 98 | """ 99 | try: 100 | return Configurator().maybe_dotted(module) 101 | except ImportError as e: 102 | err = '%s not found. %s' % (module, e) 103 | if throw: 104 | raise ImportError(err) 105 | else: 106 | log.error(err) 107 | return None 108 | 109 | 110 | @contextmanager 111 | def chdir(path): 112 | old_dir = os.getcwd() 113 | os.chdir(path) 114 | yield 115 | os.chdir(old_dir) 116 | 117 | 118 | def isnumeric(value): 119 | """Return True if `value` can be converted to a float.""" 120 | try: 121 | float(value) 122 | return True 123 | except (ValueError, TypeError): 124 | return False 125 | 126 | 127 | def issequence(arg): 128 | """Return True if `arg` acts as a list and does not look like a string.""" 129 | string_behaviour = ( 130 | isinstance(arg, six.string_types) or 131 | isinstance(arg, six.text_type)) 132 | list_behaviour = hasattr(arg, '__getitem__') or hasattr(arg, '__iter__') 133 | return not string_behaviour and list_behaviour 134 | 135 | 136 | def merge_dicts(a, b, path=None): 137 | """ Merge dict :b: into dict :a: 138 | 139 | Code snippet from http://stackoverflow.com/a/7205107 140 | """ 141 | if path is None: 142 | path = [] 143 | 144 | for key in b: 145 | if key in a: 146 | if isinstance(a[key], dict) and isinstance(b[key], dict): 147 | merge_dicts(a[key], b[key], path + [str(key)]) 148 | elif a[key] == b[key]: 149 | pass # same leaf value 150 | else: 151 | raise Exception( 152 | 'Conflict at %s' % '.'.join(path + [str(key)])) 153 | else: 154 | a[key] = b[key] 155 | return a 156 | 157 | 158 | def str2dict(dotted_str, value=None, separator='.'): 159 | """ Convert dotted string to dict splitting by :separator: """ 160 | dict_ = {} 161 | parts = dotted_str.split(separator) 162 | d, prev = dict_, None 163 | for part in parts: 164 | prev = d 165 | d = d.setdefault(part, {}) 166 | else: 167 | if value is not None: 168 | prev[part] = value 169 | return dict_ 170 | 171 | 172 | def validate_data_privacy(request, data, wrapper_kw=None): 173 | """ Validate :data: contains only data allowed by privacy settings. 174 | 175 | :param request: Pyramid Request instance 176 | :param data: Dict containing request/response data which should be 177 | validated 178 | """ 179 | from nefertari import wrappers 180 | if wrapper_kw is None: 181 | wrapper_kw = {} 182 | 183 | wrapper = wrappers.apply_privacy(request) 184 | allowed_fields = wrapper(result=data, **wrapper_kw).keys() 185 | data = data.copy() 186 | data.pop('_type', None) 187 | not_allowed_fields = set(data.keys()) - set(allowed_fields) 188 | 189 | if not_allowed_fields: 190 | raise wrappers.ValidationError(', '.join(not_allowed_fields)) 191 | 192 | 193 | def drop_reserved_params(params): 194 | """ Drops reserved params """ 195 | from nefertari import RESERVED_PARAMS 196 | params = params.copy() 197 | for reserved_param in RESERVED_PARAMS: 198 | if reserved_param in params: 199 | params.pop(reserved_param) 200 | return params 201 | 202 | 203 | def is_document(data): 204 | """ Determine whether :data: is a valid document. 205 | 206 | To be considered valid, data must: 207 | * Be an instance of dict 208 | * Have '_type' key in it 209 | """ 210 | return isinstance(data, dict) and '_type' in data 211 | -------------------------------------------------------------------------------- /tests/test_polymorphic.py: -------------------------------------------------------------------------------- 1 | from mock import Mock, patch 2 | 3 | from nefertari import polymorphic 4 | from nefertari.renderers import _JSONEncoder 5 | 6 | 7 | class TestPolymorphicHelperMixin(object): 8 | def test_get_collections(self): 9 | mixin = polymorphic.PolymorphicHelperMixin() 10 | mixin.request = Mock(matchdict={ 11 | 'collections': 'stories ,users,users/foo'}) 12 | assert mixin.get_collections() == set(['stories', 'users']) 13 | 14 | def test_get_resources(self): 15 | mixin = polymorphic.PolymorphicHelperMixin() 16 | mixin.request = Mock() 17 | resource1 = Mock(collection_name='stories') 18 | resource2 = Mock(collection_name='foo') 19 | mixin.request.registry._model_collections = { 20 | 'bar': resource1, 21 | 'baz': resource2, 22 | } 23 | resources = mixin.get_resources(['stories']) 24 | assert resources == set([resource1]) 25 | 26 | 27 | class TestPolymorphicACL(object): 28 | 29 | @patch.object(polymorphic.PolymorphicACL, 'set_collections_acl') 30 | def test_init(self, mock_meth): 31 | polymorphic.PolymorphicACL(None) 32 | mock_meth.assert_called_once_with() 33 | 34 | @patch.object(polymorphic.PolymorphicACL, 'set_collections_acl') 35 | def test_get_least_permissions_aces_not_allowed(self, mock_meth): 36 | request = Mock() 37 | request.has_permission.return_value = False 38 | acl = polymorphic.PolymorphicACL(request) 39 | resource = Mock() 40 | resource.view._factory = Mock() 41 | assert acl._get_least_permissions_aces([resource]) is None 42 | resource.view._factory.assert_called_once_with(request) 43 | request.has_permission.assert_called_once_with( 44 | 'view', resource.view._factory()) 45 | 46 | @patch.object(polymorphic.PolymorphicACL, 'set_collections_acl') 47 | def test_get_least_permissions_aces_allowed(self, mock_meth): 48 | from pyramid.security import Allow 49 | request = Mock() 50 | request.has_permission.return_value = True 51 | request.effective_principals = ['user', 'admin'] 52 | acl = polymorphic.PolymorphicACL(request) 53 | resource = Mock() 54 | resource.view._factory = Mock() 55 | aces = acl._get_least_permissions_aces([resource]) 56 | resource.view._factory.assert_called_once_with(request) 57 | request.has_permission.assert_called_once_with( 58 | 'view', resource.view._factory()) 59 | assert len(aces) == 2 60 | assert (Allow, 'user', 'view') in aces 61 | assert (Allow, 'admin', 'view') in aces 62 | 63 | @patch.object(polymorphic.PolymorphicACL, '_get_least_permissions_aces') 64 | @patch.object(polymorphic.PolymorphicACL, 'get_resources') 65 | @patch.object(polymorphic.PolymorphicACL, 'get_collections') 66 | def test_set_collections_acl_no_aces(self, mock_coll, mock_res, 67 | mock_aces): 68 | from pyramid.security import DENY_ALL 69 | mock_coll.return_value = ['stories', 'users'] 70 | mock_res.return_value = ['foo', 'bar'] 71 | mock_aces.return_value = None 72 | acl = polymorphic.PolymorphicACL(None) 73 | assert len(acl.__acl__) == 2 74 | assert DENY_ALL == acl.__acl__[-1] 75 | mock_coll.assert_called_once_with() 76 | mock_res.assert_called_once_with(['stories', 'users']) 77 | mock_aces.assert_called_once_with(['foo', 'bar']) 78 | 79 | @patch.object(polymorphic.PolymorphicACL, '_get_least_permissions_aces') 80 | @patch.object(polymorphic.PolymorphicACL, 'get_resources') 81 | @patch.object(polymorphic.PolymorphicACL, 'get_collections') 82 | def test_set_collections_acl_has_aces(self, mock_coll, mock_res, 83 | mock_aces): 84 | from pyramid.security import Allow, DENY_ALL 85 | aces = [(Allow, 'foobar', 'dostuff')] 86 | mock_aces.return_value = aces 87 | acl = polymorphic.PolymorphicACL(None) 88 | assert len(acl.__acl__) == 3 89 | assert DENY_ALL == acl.__acl__[-1] 90 | assert aces[0] in acl.__acl__ 91 | assert mock_coll.call_count == 1 92 | assert mock_res.call_count == 1 93 | assert mock_aces.call_count == 1 94 | 95 | 96 | class TestPolymorphicESView(object): 97 | 98 | class DummyPolymorphicESView(polymorphic.PolymorphicESView): 99 | _json_encoder = _JSONEncoder 100 | 101 | def _dummy_view(self): 102 | request = Mock(content_type='', method='', accept=[''], user=None) 103 | return self.DummyPolymorphicESView( 104 | context={}, request=request, 105 | _json_params={'foo': 'bar'}, 106 | _query_params={'foo1': 'bar1'}) 107 | 108 | @patch.object(polymorphic.PolymorphicESView, 'determine_types') 109 | def test_ini(self, mock_det): 110 | from nefertari.utils import dictset 111 | mock_det.return_value = ['story', 'user'] 112 | view = self._dummy_view() 113 | mock_det.assert_called_once_with() 114 | assert isinstance(view.Model, dictset) 115 | assert dict(view.Model) == {'__name__': 'story,user'} 116 | 117 | @patch.object(polymorphic.PolymorphicESView, 'determine_types') 118 | @patch.object(polymorphic.PolymorphicESView, 'set_public_limits') 119 | @patch.object(polymorphic.PolymorphicESView, 'setup_default_wrappers') 120 | def test_run_init_actions(self, mock_wraps, mock_lims, mock_det): 121 | self._dummy_view() 122 | mock_wraps.assert_called_once_with() 123 | mock_lims.assert_called_once_with() 124 | 125 | @patch.object(polymorphic.PolymorphicESView, 'get_resources') 126 | @patch.object(polymorphic.PolymorphicESView, 'get_collections') 127 | def test_determine_types(self, mock_coll, mock_res): 128 | mock_coll.return_value = ['stories', 'users'] 129 | stories_res = Mock() 130 | stories_res.view.Model = Mock( 131 | __name__='StoryFoo', _index_enabled=True) 132 | users_res = Mock() 133 | users_res.view.Model = Mock( 134 | __name__='UserFoo', _index_enabled=False) 135 | mock_res.return_value = [stories_res, users_res] 136 | view = self._dummy_view() 137 | types = view.determine_types() 138 | assert types == ['StoryFoo'] 139 | mock_coll.assert_called_with() 140 | mock_res.assert_called_with(['stories', 'users']) 141 | 142 | @patch.object(polymorphic.PolymorphicESView, 'get_collection_es') 143 | @patch.object(polymorphic.PolymorphicESView, 'determine_types') 144 | def test_index(self, mock_det, mock_get): 145 | view = self._dummy_view() 146 | response = view.index('foo') 147 | mock_get.assert_called_once_with() 148 | assert response == mock_get() 149 | assert view._query_params['_limit'] == 20 150 | -------------------------------------------------------------------------------- /nefertari/renderers.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from datetime import date, datetime 4 | 5 | from nefertari import wrappers 6 | from nefertari.utils import get_json_encoder 7 | from nefertari.json_httpexceptions import JHTTPOk, JHTTPCreated 8 | from nefertari.events import trigger_after_events 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | class _JSONEncoder(json.JSONEncoder): 14 | def default(self, obj): 15 | if isinstance(obj, (datetime, date)): 16 | return obj.strftime("%Y-%m-%dT%H:%M:%SZ") # iso 17 | 18 | try: 19 | return super(_JSONEncoder, self).default(obj) 20 | except TypeError: 21 | return str(obj) # fallback to str 22 | 23 | 24 | class JsonRendererFactory(object): 25 | 26 | def __init__(self, info): 27 | """ Constructor: info will be an object having the 28 | following attributes: name (the renderer name), package 29 | (the package that was 'current' at the time the 30 | renderer was registered), type (the renderer type 31 | name), registry (the current application registry) and 32 | settings (the deployment settings dictionary). """ 33 | pass 34 | 35 | def _set_content_type(self, system): 36 | """ Set response content type """ 37 | request = system.get('request') 38 | if request: 39 | response = request.response 40 | ct = response.content_type 41 | if ct == response.default_content_type: 42 | response.content_type = 'application/json' 43 | 44 | def _render_response(self, value, system): 45 | """ Render a response """ 46 | view = system['view'] 47 | enc_class = getattr(view, '_json_encoder', None) 48 | if enc_class is None: 49 | enc_class = get_json_encoder() 50 | return json.dumps(value, cls=enc_class) 51 | 52 | def __call__(self, value, system): 53 | """ Call the renderer implementation with the value 54 | and the system value passed in as arguments and return 55 | the result (a string or unicode object). The value is 56 | the return value of a view. The system value is a 57 | dictionary containing available system values 58 | (e.g. view, context, and request). 59 | """ 60 | self._set_content_type(system) 61 | # run after_calls on the value before jsonifying 62 | value = self.run_after_calls(value, system) 63 | value = self._trigger_events(value, system) 64 | return self._render_response(value, system) 65 | 66 | def _trigger_events(self, value, system): 67 | view_obj = system['view'](system['context'], system['request']) 68 | view_obj._response = value 69 | evt = trigger_after_events(view_obj) 70 | return evt.response 71 | 72 | def run_after_calls(self, value, system): 73 | request = system.get('request') 74 | if request and hasattr(request, 'action'): 75 | 76 | if request.action in ['index', 'show']: 77 | value = wrappers.wrap_in_dict(request)(result=value) 78 | 79 | return value 80 | 81 | 82 | class DefaultResponseRendererMixin(object): 83 | """ Renderer mixin that generates responses for all create/update/delete 84 | view methods. 85 | """ 86 | def _get_common_kwargs(self, system): 87 | """ Get kwargs common for all methods. """ 88 | enc_class = getattr(system['view'], '_json_encoder', None) 89 | if enc_class is None: 90 | enc_class = get_json_encoder() 91 | return { 92 | 'request': system['request'], 93 | 'encoder': enc_class, 94 | } 95 | 96 | def _get_create_update_kwargs(self, value, common_kw): 97 | """ Get kwargs common to create, update, replace. """ 98 | kw = common_kw.copy() 99 | kw['body'] = value 100 | if '_self' in value: 101 | kw['headers'] = [('Location', value['_self'])] 102 | return kw 103 | 104 | def render_create(self, value, system, common_kw): 105 | """ Render response for view `create` method (collection POST) """ 106 | kw = self._get_create_update_kwargs(value, common_kw) 107 | return JHTTPCreated(**kw) 108 | 109 | def render_update(self, value, system, common_kw): 110 | """ Render response for view `update` method (item PATCH) """ 111 | kw = self._get_create_update_kwargs(value, common_kw) 112 | return JHTTPOk('Updated', **kw) 113 | 114 | def render_replace(self, *args, **kwargs): 115 | """ Render response for view `replace` method (item PUT) """ 116 | return self.render_update(*args, **kwargs) 117 | 118 | def render_delete(self, value, system, common_kw): 119 | """ Render response for view `delete` method (item DELETE) """ 120 | return JHTTPOk('Deleted', **common_kw.copy()) 121 | 122 | def render_delete_many(self, value, system, common_kw): 123 | """ Render response for view `delete_many` method (collection DELETE) 124 | """ 125 | if isinstance(value, dict): 126 | return JHTTPOk(extra=value) 127 | msg = 'Deleted {} {}(s) objects'.format( 128 | value, system['view'].Model.__name__) 129 | return JHTTPOk(msg, **common_kw.copy()) 130 | 131 | def render_update_many(self, value, system, common_kw): 132 | """ Render response for view `update_many` method 133 | (collection PUT/PATCH) 134 | """ 135 | msg = 'Updated {} {}(s) objects'.format( 136 | value, system['view'].Model.__name__) 137 | return JHTTPOk(msg, **common_kw.copy()) 138 | 139 | def _render_response(self, value, system): 140 | """ Handle response rendering. 141 | 142 | Calls mixin methods according to request.action value. 143 | """ 144 | super_call = super(DefaultResponseRendererMixin, self)._render_response 145 | try: 146 | method_name = 'render_{}'.format(system['request'].action) 147 | except (KeyError, AttributeError): 148 | return super_call(value, system) 149 | method = getattr(self, method_name, None) 150 | if method is not None: 151 | common_kw = self._get_common_kwargs(system) 152 | response = method(value, system, common_kw) 153 | system['request'].response = response 154 | return 155 | return super_call(value, system) 156 | 157 | 158 | class NefertariJsonRendererFactory(DefaultResponseRendererMixin, 159 | JsonRendererFactory): 160 | """ Special json renderer which will apply all after_calls(filters) 161 | to the result. 162 | """ 163 | def run_after_calls(self, value, system): 164 | request = system.get('request') 165 | if request and hasattr(request, 'action'): 166 | after_calls = getattr(request, 'filters', {}) 167 | for call in after_calls.get(request.action, []): 168 | value = call(**dict(request=request, result=value)) 169 | return value 170 | -------------------------------------------------------------------------------- /nefertari/polymorphic.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module that defines all the objects required to handle polymorphic 3 | collection read requests. 4 | 5 | Only ES-based models that have collection view registered are handled 6 | by this module. 7 | 8 | Im particular: 9 | * PolymorphicACL: Dynamic factory class that generates ACL considering 10 | ACLs of all the collections requested. 11 | * PolymorphicESView: View that handles polymorphic collection read 12 | requests. 13 | 14 | To use this module, simply include it in your `main()` after 15 | Pyramid ACLAuthorizationPolicy is set up and nefertari is included. 16 | 17 | By default this module is included by 'nefertari.elasticsearch' when 18 | `elasticsearch.enable_polymorphic_query` setting is True. 19 | 20 | After inclusion, PolymorphicESView view will be registered to handle GET 21 | requests. To access polymorphic API endpoint, compose URL with names 22 | used to access collection GET API endpoints. 23 | 24 | E.g. If API had collection endpoints '/users' and '/stories', polymorphic 25 | endpoint would be available at '/users,stories' and '/stories,users'. 26 | 27 | Polymorphic endpoints support all the read functionality regular ES 28 | endpoint supports: query, search, filter, sort, aggregation, etc. 29 | """ 30 | from pyramid.security import DENY_ALL, Allow, ALL_PERMISSIONS 31 | 32 | from nefertari.view import BaseView 33 | from nefertari.acl import CollectionACL 34 | from nefertari.utils import dictset 35 | 36 | 37 | def includeme(config): 38 | """ Connect view to route that catches all URIs like 39 | 'something,something,...' 40 | """ 41 | root = config.get_root_resource() 42 | root.add('nef_polymorphic', '{collections:.+,.+}', 43 | view=PolymorphicESView, 44 | factory=PolymorphicACL) 45 | 46 | 47 | class PolymorphicHelperMixin(object): 48 | """ Helper mixin class that contains methods used by: 49 | * PolymorphicACL 50 | * PolymorphicESView 51 | """ 52 | def get_collections(self): 53 | """ Get names of collections from request matchdict. 54 | 55 | :return: Names of collections 56 | :rtype: list of str 57 | """ 58 | collections = self.request.matchdict['collections'].split('/')[0] 59 | collections = [coll.strip() for coll in collections.split(',')] 60 | return set(collections) 61 | 62 | def get_resources(self, collections): 63 | """ Get resources that correspond to values from :collections:. 64 | 65 | :param collections: Collection names for which resources should be 66 | gathered 67 | :type collections: list of str 68 | :return: Gathered resources 69 | :rtype: list of Resource instances 70 | """ 71 | res_map = self.request.registry._model_collections 72 | resources = [res for res in res_map.values() 73 | if res.collection_name in collections] 74 | resources = [res for res in resources if res] 75 | return set(resources) 76 | 77 | 78 | class PolymorphicACL(PolymorphicHelperMixin, CollectionACL): 79 | """ ACL used by PolymorphicESView. 80 | 81 | Generates ACEs checking whether current request user has 'view' 82 | permissions in all of the requested collection views/contexts. 83 | """ 84 | def __init__(self, request): 85 | """ Set ACL generated from collections affected. """ 86 | super(PolymorphicACL, self).__init__(request) 87 | self.set_collections_acl() 88 | 89 | def _get_least_permissions_aces(self, resources): 90 | """ Get ACEs with the least permissions that fit all resources. 91 | 92 | To have access to polymorph on N collections, user MUST have 93 | access to all of them. If this is true, ACEs are returned, that 94 | allows 'view' permissions to current request principals. 95 | 96 | Otherwise None is returned thus blocking all permissions except 97 | those defined in `nefertari.acl.BaseACL`. 98 | 99 | :param resources: 100 | :type resources: list of Resource instances 101 | :return: Generated Pyramid ACEs or None 102 | :rtype: tuple or None 103 | """ 104 | factories = [res.view._factory for res in resources] 105 | contexts = [factory(self.request) for factory in factories] 106 | for ctx in contexts: 107 | if not self.request.has_permission('view', ctx): 108 | return 109 | else: 110 | return [ 111 | (Allow, principal, 'view') 112 | for principal in self.request.effective_principals 113 | ] 114 | 115 | def set_collections_acl(self): 116 | """ Calculate and set ACL valid for requested collections. 117 | 118 | DENY_ALL is added to ACL to make sure no access rules are 119 | inherited. 120 | """ 121 | acl = [(Allow, 'g:admin', ALL_PERMISSIONS)] 122 | collections = self.get_collections() 123 | resources = self.get_resources(collections) 124 | aces = self._get_least_permissions_aces(resources) 125 | if aces is not None: 126 | for ace in aces: 127 | acl.append(ace) 128 | acl.append(DENY_ALL) 129 | self.__acl__ = tuple(acl) 130 | 131 | 132 | class PolymorphicESView(PolymorphicHelperMixin, BaseView): 133 | """ Polymorphic ES collection read view. 134 | 135 | Has default implementation of 'index' view method that supports 136 | all the ES collection read actions(query, aggregation, etc.) across 137 | multiple collections of ES-based documents. 138 | 139 | To be displayed by polymorphic view, model must have collection view 140 | setup that serves instances of this model. Models that only have 141 | singular views setup are not served by polymorhic view. 142 | """ 143 | def __init__(self, *args, **kwargs): 144 | """ Init view and set fake `self.Model` so its __name__ would 145 | contain names of all requested collections. 146 | """ 147 | super(PolymorphicESView, self).__init__(*args, **kwargs) 148 | types = self.determine_types() 149 | self.Model = dictset({'__name__': ','.join(types)}) 150 | 151 | def _run_init_actions(self): 152 | self.setup_default_wrappers() 153 | self.set_public_limits() 154 | 155 | def determine_types(self): 156 | """ Determine ES type names from request data. 157 | 158 | In particular `request.matchdict['collections']` is used to 159 | determine types names. Its value is comma-separated sequence 160 | of collection names under which views have been registered. 161 | """ 162 | from nefertari.elasticsearch import ES 163 | collections = self.get_collections() 164 | resources = self.get_resources(collections) 165 | models = set([res.view.Model for res in resources]) 166 | es_models = [mdl for mdl in models if mdl 167 | and getattr(mdl, '_index_enabled', False)] 168 | types = [ES.src2type(mdl.__name__) for mdl in es_models] 169 | return types 170 | 171 | def index(self, collections): 172 | """ Handle collection GET request. 173 | 174 | Set default limit and call :self.get_collection_es: to query ES. 175 | """ 176 | self._query_params.process_int_param('_limit', 20) 177 | return self.get_collection_es() 178 | -------------------------------------------------------------------------------- /docs/source/event_handlers.rst: -------------------------------------------------------------------------------- 1 | Event Handlers 2 | ============== 3 | 4 | Nefertari event handler module includes a set of events, maps of events, event handler predicates and helper function to connect it all together. All the objects are contained in ``nefertari.events`` module. Nefertari event handlers use Pyramid event system. 5 | 6 | 7 | Events 8 | ------ 9 | 10 | ``nefertari.events`` defines a set of event classes inherited from ``nefertari.events.RequestEvent``, ``nefertari.events.BeforeEvent`` and ``nefertari.events.AfterEvent``. 11 | 12 | There are two types of nefertari events: 13 | * "Before" events, which are run after view class is instantiated, but before view method is run, and before request is processed. Can be used to edit the request. 14 | * "After" events, which are run after view method has been called. Can be used to edit the response. 15 | 16 | Check the API section for a full list of attributes/params events have. 17 | 18 | Complete list of events: 19 | * BeforeIndex 20 | * BeforeShow 21 | * BeforeCreate 22 | * BeforeUpdate 23 | * BeforeReplace 24 | * BeforeDelete 25 | * BeforeUpdateMany 26 | * BeforeDeleteMany 27 | * BeforeItemOptions 28 | * BeforeCollectionOptions 29 | * AfterIndex 30 | * AfterShow 31 | * AfterCreate 32 | * AfterUpdate 33 | * AfterReplace 34 | * AfterDelete 35 | * AfterUpdateMany 36 | * AfterDeleteMany 37 | * AfterItemOptions 38 | * AfterCollectionOptions 39 | 40 | All events are named after camel-cased name of view method they are called around and prefixed with "Before" or "After" depending on the place event is triggered from (as described above). E.g. event classed for view method ``update_many`` are called ``BeforeUpdateMany`` and ``AfterUpdateMany``. 41 | 42 | 43 | Before vs After 44 | --------------- 45 | 46 | It is recommended to use ``before`` events to: 47 | * Transform input 48 | * Perform validation 49 | * Apply changes to object that is being affected by request using ``event.set_field_value`` method 50 | 51 | And ``after`` events to: 52 | * Change DB objects which are not affected by request 53 | * Perform notifications/logging 54 | * Edit a response using ``event.set_field_value`` method 55 | 56 | Note: if a field changed via ``event.set_field_value`` is not affected by the request, it will be added to ``event.fields`` which will make any field processors attached to this field to be triggered, if they are run after this method call (connected to events after handler that performs method call). 57 | 58 | 59 | Predicates 60 | ---------- 61 | 62 | **nefertari.events.ModelClassIs** 63 | Available under ``model`` param when connecting event handlers, it allows to connect event handlers on per-model basis. When event handler is connected using this predicate, it will only be called when ``view.Model`` is the same class or subclass of this param value. 64 | 65 | 66 | Utilities 67 | ---------- 68 | 69 | **nefertari.events.subscribe_to_events** 70 | Helper function that allows to connect event handler to multiple events at once. Supports ``model`` event handler predicate param. Available at ``config.subscribe_to_events``. Subscribers are run in order connected. 71 | 72 | **nefertari.events.BEFORE_EVENTS** 73 | Map of ``{view_method_name: EventClass}`` of "Before" events. E.g. one of its elements is ``'index': BeforeIndex``. 74 | 75 | **nefertari.events.AFTER_EVENTS** 76 | Map of ``{view_method_name: EventClass}`` of "AFter" events. E.g. one of its elements is ``'index': AfterIndex``. 77 | 78 | **nefertari.events.silent** 79 | Decorator which marks view class or view method as "silent". Silent view classes and methods don't fire events. In the example below, view ``ItemsView`` won't fire any event. ``UsersView`` won't fire ``BeforeIndex`` and ``AfterIndex`` events but ``BeforeShow`` and ``AfterShow`` events will be fired. 80 | 81 | .. code-block:: python 82 | 83 | from nefertari import view, events 84 | 85 | 86 | @events.silent 87 | class ItemsView(view.BaseView): 88 | ... 89 | 90 | 91 | class UsersView(view.BaseView): 92 | 93 | @events.silent 94 | def index(self): 95 | ... 96 | 97 | def show(self): 98 | ... 99 | 100 | **nefertari.events.trigger_instead** 101 | Decorator which allows view method to trigger another event instead of the default one. In the example above collection GET requests (``UsersView.index``) will trigger the event which corresponds to item PATCH (``update``). 102 | 103 | .. code-block:: python 104 | 105 | from nefertari import view, events 106 | 107 | 108 | class UsersView(view.BaseView): 109 | 110 | @events.trigger_instead('update') 111 | def index(self): 112 | ... 113 | 114 | 115 | Example 116 | ------- 117 | 118 | We will use the following example to demonstrate how to connect handlers to events. This handler logs ``request`` to the console. 119 | 120 | .. code-block:: python 121 | 122 | import logging 123 | log = logging.getLogger(__name__) 124 | 125 | 126 | def log_request(event): 127 | log.debug(event.request.body) 128 | 129 | 130 | We can connect this handler to any of Nefertari events of any requests. E.g. lets log all collection POST after requests are made (view ``create`` method): 131 | 132 | .. code-block:: python 133 | 134 | from nefertari import events 135 | 136 | 137 | config.subscribe_to_events( 138 | log_request, [events.AfterCreate]) 139 | 140 | 141 | Or, if we wanted to limit the models for which this handler should be called, we can connect it with a ``model`` predicate: 142 | 143 | .. code-block:: python 144 | 145 | from nefertari import events 146 | from .models import User 147 | 148 | 149 | config.subscribe_to_events( 150 | log_request, [events.AfterCreate], 151 | model=User) 152 | 153 | This way, ``log_request`` event handler will only be called when collection POST request comes at an endpoint which handles the ``User`` model. 154 | 155 | Whenever the response has to be edited, ``after`` events with ``event.set_field_value`` must be used. If the response contains a single item, calling this method will change this single item's field. Otherwise field will be changed for all the objects in the response. To edit the response meta, you can access ``event.response`` directly. ``event.response`` is a view method response serialized into ``dict``. 156 | 157 | E.g. if we want to hide user passwords from response on collection and item GET: 158 | 159 | .. code-block:: python 160 | 161 | from nefertari import events 162 | from .models import User 163 | 164 | def hide_user_passwords(event): 165 | # Hide 'password' field 166 | event.set_field_value('password', 'VERY_SECRET') 167 | 168 | config.subscribe_to_events( 169 | hide_user_passwords, 170 | [events.AfterIndex, events.AfterShow], 171 | model=User) 172 | 173 | 174 | API 175 | --- 176 | 177 | .. autoclass:: nefertari.events.RequestEvent 178 | :members: 179 | :private-members: 180 | 181 | .. autoclass:: nefertari.events.ModelClassIs 182 | :members: 183 | :private-members: 184 | 185 | .. autofunction:: nefertari.events.trigger_events 186 | 187 | .. autofunction:: nefertari.events.subscribe_to_events 188 | 189 | .. autofunction:: nefertari.events.silent 190 | 191 | .. autofunction:: nefertari.events.trigger_instead 192 | -------------------------------------------------------------------------------- /nefertari/authentication/views.py: -------------------------------------------------------------------------------- 1 | from pyramid.security import remember, forget 2 | 3 | from nefertari.json_httpexceptions import ( 4 | JHTTPFound, JHTTPConflict, JHTTPUnauthorized, JHTTPNotFound, JHTTPOk, 5 | JHTTPBadRequest) 6 | from nefertari.view import BaseView 7 | from nefertari import events 8 | 9 | 10 | class TicketAuthViewMixin(object): 11 | """ View mixin for auth operations to use with Pyramid ticket-based auth. 12 | `login` (POST): Login the user with 'login' and 'password' 13 | `logout`: Logout user 14 | """ 15 | def register(self): 16 | """ Register new user by POSTing all required data. """ 17 | user, created = self.Model.create_account( 18 | self._json_params) 19 | 20 | if not created: 21 | raise JHTTPConflict('Looks like you already have an account.') 22 | 23 | self.request._user = user 24 | pk_field = user.pk_field() 25 | headers = remember(self.request, getattr(user, pk_field)) 26 | return JHTTPOk('Registered', headers=headers) 27 | 28 | def login(self, **params): 29 | self._json_params.update(params) 30 | next = self._query_params.get('next', '') 31 | if self.request.path in next: 32 | next = '' # never use the login form itself as next 33 | 34 | unauthorized_url = self._query_params.get('unauthorized', None) 35 | success, user = self.Model.authenticate_by_password( 36 | self._json_params) 37 | 38 | if success: 39 | pk_field = user.pk_field() 40 | headers = remember(self.request, getattr(user, pk_field)) 41 | if next: 42 | raise JHTTPFound(location=next, headers=headers) 43 | else: 44 | return JHTTPOk('Logged in', headers=headers) 45 | if user: 46 | if unauthorized_url: 47 | return JHTTPUnauthorized(location=unauthorized_url+'?error=1') 48 | 49 | raise JHTTPUnauthorized('Failed to Login.') 50 | else: 51 | raise JHTTPNotFound('User not found') 52 | 53 | def logout(self): 54 | next = self._query_params.get('next') 55 | headers = forget(self.request) 56 | if next: 57 | return JHTTPFound(location=next, headers=headers) 58 | return JHTTPOk('Logged out', headers=headers) 59 | 60 | 61 | class TicketAuthRegisterView(TicketAuthViewMixin, BaseView): 62 | """ Ticket auth register view. 63 | 64 | Register with:: 65 | root = config.get_root_resource() 66 | root.add('account', view='path.to.TicketAuthRegisterView', 67 | factory='nefertari.acl.AuthenticationACL') 68 | """ 69 | @events.trigger_instead('register') 70 | def create(self, *args, **kwargs): 71 | return self.register(*args, **kwargs) 72 | 73 | 74 | class TicketAuthLoginView(TicketAuthViewMixin, BaseView): 75 | """ Ticket auth login view. 76 | 77 | Register with:: 78 | root = config.get_root_resource() 79 | root.add('login', view='path.to.TicketAuthLoginView', 80 | factory='nefertari.acl.AuthenticationACL') 81 | """ 82 | @events.trigger_instead('login') 83 | def create(self, *args, **kwargs): 84 | return self.login(*args, **kwargs) 85 | 86 | 87 | class TicketAuthLogoutView(TicketAuthViewMixin, BaseView): 88 | """ Ticket auth logout view. Allows logout on GET and POST. 89 | 90 | Register with:: 91 | root = config.get_root_resource() 92 | root.add('logout', view='path.to.TicketAuthLogoutView', 93 | factory='nefertari.acl.AuthenticationACL') 94 | """ 95 | @events.trigger_instead('logout') 96 | def create(self, *args, **kwargs): 97 | return self.logout(*args, **kwargs) 98 | 99 | @events.trigger_instead('logout') 100 | def show(self, *args, **kwargs): 101 | return self.logout(*args, **kwargs) 102 | 103 | 104 | class TokenAuthViewMixin(object): 105 | """ View for auth operations to use with 106 | `nefertari.authentication.policies.ApiKeyAuthenticationPolicy` 107 | token-based auth. Implements methods: 108 | """ 109 | def register(self): 110 | """ Register a new user by POSTing all required data. 111 | 112 | User's `Authorization` header value is returned in `WWW-Authenticate` 113 | header. 114 | """ 115 | user, created = self.Model.create_account(self._json_params) 116 | if user.api_key is None: 117 | raise JHTTPBadRequest('Failed to generate ApiKey for user') 118 | 119 | if not created: 120 | raise JHTTPConflict('Looks like you already have an account.') 121 | 122 | self.request._user = user 123 | headers = remember(self.request, user.username) 124 | return JHTTPOk('Registered', headers=headers) 125 | 126 | def claim_token(self, **params): 127 | """Claim current token by POSTing 'login' and 'password'. 128 | 129 | User's `Authorization` header value is returned in `WWW-Authenticate` 130 | header. 131 | """ 132 | self._json_params.update(params) 133 | success, self.user = self.Model.authenticate_by_password( 134 | self._json_params) 135 | 136 | if success: 137 | headers = remember(self.request, self.user.username) 138 | return JHTTPOk('Token claimed', headers=headers) 139 | if self.user: 140 | raise JHTTPUnauthorized('Wrong login or password') 141 | else: 142 | raise JHTTPNotFound('User not found') 143 | 144 | def reset_token(self, **params): 145 | """ Reset current token by POSTing 'login' and 'password'. 146 | 147 | User's `Authorization` header value is returned in `WWW-Authenticate` 148 | header. 149 | """ 150 | response = self.claim_token(**params) 151 | if not self.user: 152 | return response 153 | 154 | self.user.api_key.reset_token() 155 | headers = remember(self.request, self.user.username) 156 | return JHTTPOk('Registered', headers=headers) 157 | 158 | 159 | class TokenAuthRegisterView(TokenAuthViewMixin, BaseView): 160 | """ Token auth register view. 161 | 162 | Register with:: 163 | root = config.get_root_resource() 164 | root.add('register', view='path.to.TokenAuthRegisterView', 165 | factory='nefertari.acl.AuthenticationACL') 166 | """ 167 | @events.trigger_instead('register') 168 | def create(self, *args, **kwargs): 169 | return self.register(*args, **kwargs) 170 | 171 | 172 | class TokenAuthClaimView(TokenAuthViewMixin, BaseView): 173 | """ Ticket auth view to claim registered token. 174 | 175 | Register with:: 176 | root = config.get_root_resource() 177 | root.add('token', view='path.to.TokenAuthClaimView', 178 | factory='nefertari.acl.AuthenticationACL') 179 | """ 180 | @events.silent 181 | def create(self, *args, **kwargs): 182 | return self.claim_token(*args, **kwargs) 183 | 184 | 185 | class TokenAuthResetView(TokenAuthViewMixin, BaseView): 186 | """ Ticket auth view to reset registered token. 187 | 188 | Register with:: 189 | root = config.get_root_resource() 190 | root.add('reset_token', view='path.to.TokenAuthResetView', 191 | factory='nefertari.acl.AuthenticationACL') 192 | """ 193 | @events.silent 194 | def create(self, *args, **kwargs): 195 | return self.reset_token(*args, **kwargs) 196 | -------------------------------------------------------------------------------- /tests/test_utils/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from mock import patch, call, Mock 3 | 4 | from nefertari.utils import utils 5 | 6 | 7 | class TestUtils(object): 8 | 9 | @patch('nefertari.engine') 10 | def test_get_json_encoder_engine(self, mock_eng): 11 | eng = utils.get_json_encoder() 12 | assert eng == mock_eng.JSONEncoder 13 | 14 | def test_get_json_encoder_default(self): 15 | from nefertari.renderers import _JSONEncoder 16 | eng = utils.get_json_encoder() 17 | assert eng is _JSONEncoder 18 | 19 | @patch('nefertari.utils.utils.get_json_encoder') 20 | @patch('nefertari.utils.utils.json') 21 | def test_json_dumps(self, mock_json, mock_get): 22 | utils.json_dumps('foo') 23 | mock_get.assert_called_once_with() 24 | mock_json.dumps.assert_called_once_with('foo', cls=mock_get()) 25 | 26 | @patch('nefertari.utils.utils.json') 27 | def test_json_dumps_encoder(self, mock_json): 28 | utils.json_dumps('foo', 'enc') 29 | mock_json.dumps.assert_called_once_with('foo', cls='enc') 30 | 31 | def test_split_strip(self): 32 | assert utils.split_strip('1, 2,') == ['1', '2'] 33 | assert utils.split_strip('1, 2') == ['1', '2'] 34 | assert utils.split_strip('1;2;', on=';') == ['1', '2'] 35 | 36 | def test_process_limit_start_and_page(self): 37 | with pytest.raises(ValueError) as ex: 38 | utils.process_limit(1, 2, 3) 39 | assert 'at the same time' in str(ex.value) 40 | 41 | def test_process_limit_start(self): 42 | start, limit = utils.process_limit(start=1, page=None, limit=5) 43 | assert start == 1 44 | assert limit == 5 45 | 46 | def test_process_limit_page(self): 47 | start, limit = utils.process_limit(start=None, page=2, limit=5) 48 | assert start == 10 49 | assert limit == 5 50 | 51 | def test_process_limit_no_start_page(self): 52 | start, limit = utils.process_limit(start=None, page=None, limit=5) 53 | assert start == 0 54 | assert limit == 5 55 | 56 | def test_process_limit_lower_than_zero(self): 57 | with pytest.raises(ValueError) as ex: 58 | utils.process_limit(1, None, -3) 59 | assert 'can not be < 0' in str(ex.value) 60 | with pytest.raises(ValueError) as ex: 61 | utils.process_limit(-1, None, 3) 62 | assert 'can not be < 0' in str(ex.value) 63 | 64 | def test_extend_list_string(self): 65 | assert utils.extend_list('foo, bar,') == ['foo', 'bar'] 66 | 67 | def test_extend_list_sequence_string(self): 68 | assert utils.extend_list(['foo, bar,']) == ['foo', 'bar'] 69 | 70 | def test_extend_list_sequence_elements(self): 71 | assert utils.extend_list(['foo', 'bar', '1,2']) == [ 72 | 'foo', 'bar', '1', '2'] 73 | 74 | def test_process_fields_string(self): 75 | only, exclude = utils.process_fields('a,b,-c') 76 | assert only == ['a', 'b'] 77 | assert exclude == ['c'] 78 | 79 | def test_process_fields_empty_field(self): 80 | only, exclude = utils.process_fields(['a', 'b', '-c', '']) 81 | assert only == ['a', 'b'] 82 | assert exclude == ['c'] 83 | 84 | def test_snake2camel(self): 85 | assert utils.snake2camel('foo_bar') == 'FooBar' 86 | assert utils.snake2camel('foobar') == 'Foobar' 87 | 88 | @patch('nefertari.utils.utils.Configurator') 89 | def test_maybe_dotted(self, mock_conf): 90 | result = utils.maybe_dotted('foo.bar') 91 | mock_conf.assert_called_once_with() 92 | mock_conf().maybe_dotted.assert_called_once_with('foo.bar') 93 | assert result == mock_conf().maybe_dotted() 94 | 95 | @patch('nefertari.utils.utils.Configurator') 96 | def test_maybe_dotted_err_throw(self, mock_conf): 97 | mock_conf.side_effect = ImportError 98 | with pytest.raises(ImportError): 99 | utils.maybe_dotted('foo.bar', throw=True) 100 | 101 | @patch('nefertari.utils.utils.Configurator') 102 | def test_maybe_dotted_err_no_throw(self, mock_conf): 103 | mock_conf.side_effect = ImportError 104 | assert utils.maybe_dotted('foo.bar', throw=False) is None 105 | 106 | @patch('nefertari.utils.utils.os') 107 | def test_chdir(self, mock_os): 108 | with utils.chdir('/tmp'): 109 | pass 110 | mock_os.getcwd.assert_called_once_with() 111 | mock_os.chdir.assert_has_calls([ 112 | call('/tmp'), call(mock_os.getcwd()) 113 | ]) 114 | 115 | def test_isnumeric(self): 116 | from decimal import Decimal 117 | assert not utils.isnumeric('asd') 118 | assert not utils.isnumeric(dict()) 119 | assert not utils.isnumeric([]) 120 | assert not utils.isnumeric(()) 121 | assert utils.isnumeric(1) 122 | assert utils.isnumeric(2.0) 123 | assert utils.isnumeric(Decimal(1)) 124 | 125 | def test_issequence(self): 126 | assert utils.issequence(dict()) 127 | assert utils.issequence([]) 128 | assert utils.issequence(()) 129 | assert not utils.issequence('asd') 130 | assert not utils.issequence(1) 131 | assert not utils.issequence(2.0) 132 | 133 | def test_merge_dicts(self): 134 | dict1 = {'a': {'b': {'c': 1}}} 135 | dict2 = {'a': {'d': 2}, 'q': 3} 136 | merged = utils.merge_dicts(dict1, dict2) 137 | assert merged == { 138 | 'a': { 139 | 'b': {'c': 1}, 140 | 'd': 2, 141 | }, 142 | 'q': 3 143 | } 144 | 145 | def test_str2dict(self): 146 | assert utils.str2dict('foo.bar') == {'foo': {'bar': {}}} 147 | 148 | def test_str2dict_value(self): 149 | assert utils.str2dict('foo.bar', value=2) == {'foo': {'bar': 2}} 150 | 151 | def test_str2dict_separator(self): 152 | assert utils.str2dict('foo:bar', value=2, separator=':') == { 153 | 'foo': {'bar': 2}} 154 | 155 | @patch('nefertari.wrappers.apply_privacy') 156 | def test_validate_data_privacy_valid(self, mock_wrapper): 157 | from nefertari import wrappers 158 | wrapper = Mock() 159 | wrapper.return_value = {'foo': 1, 'bar': 2} 160 | mock_wrapper.return_value = wrapper 161 | data = {'foo': None, '_type': 'ASD'} 162 | try: 163 | utils.validate_data_privacy(None, data) 164 | except wrappers.ValidationError: 165 | raise Exception('Unexpected error') 166 | mock_wrapper.assert_called_once_with(None) 167 | wrapper.assert_called_once_with(result=data) 168 | 169 | @patch('nefertari.wrappers.apply_privacy') 170 | def test_validate_data_privacy_invalid(self, mock_wrapper): 171 | from nefertari import wrappers 172 | wrapper = Mock() 173 | wrapper.return_value = {'foo': 1, 'bar': 2} 174 | mock_wrapper.return_value = wrapper 175 | data = {'qoo': None, '_type': 'ASD'} 176 | with pytest.raises(wrappers.ValidationError) as ex: 177 | utils.validate_data_privacy(None, data) 178 | 179 | assert str(ex.value) == 'qoo' 180 | mock_wrapper.assert_called_once_with(None) 181 | wrapper.assert_called_once_with(result=data) 182 | 183 | def test_drop_reserved_params(self): 184 | from nefertari import RESERVED_PARAMS 185 | reserved_param = RESERVED_PARAMS[0] 186 | result = utils.drop_reserved_params({reserved_param: 1, 'foo': 2}) 187 | assert result == {'foo': 2} 188 | 189 | def test_is_document(self): 190 | assert not utils.is_document([1]) 191 | assert not utils.is_document('foo') 192 | assert not utils.is_document({'id': 1}) 193 | assert utils.is_document({'id': 1, '_type': 'foo'}) 194 | -------------------------------------------------------------------------------- /nefertari/view_helpers.py: -------------------------------------------------------------------------------- 1 | import six 2 | 3 | from nefertari.utils import dictset, validate_data_privacy 4 | from nefertari import wrappers 5 | from nefertari.json_httpexceptions import JHTTPForbidden 6 | 7 | 8 | class OptionsViewMixin(object): 9 | """ Mixin that implements default handling of OPTIONS requests. 10 | 11 | Is used with nefertari.view.BaseView. Relies on last view variables 12 | and methods. 13 | 14 | Attributes: 15 | :_item_actions: Map of item routes action names to tuple of HTTP 16 | methods they handle 17 | :_collection_actions: Map of collection routes action names to 18 | tuple of HTTP methods they handle 19 | """ 20 | _item_actions = { 21 | 'show': ('GET', 'HEAD'), 22 | 'replace': ('PUT',), 23 | 'update': ('PATCH',), 24 | 'delete': ('DELETE',), 25 | } 26 | _collection_actions = { 27 | 'index': ('GET', 'HEAD'), 28 | 'create': ('POST',), 29 | 'update_many': ('PUT', 'PATCH'), 30 | 'delete_many': ('DELETE',), 31 | } 32 | 33 | def _set_options_headers(self, methods): 34 | """ Set proper headers. 35 | 36 | Sets following headers: 37 | Allow 38 | Access-Control-Allow-Methods 39 | Access-Control-Allow-Headers 40 | 41 | Arguments: 42 | :methods: Sequence of HTTP method names that are value for 43 | requested URI 44 | """ 45 | request = self.request 46 | response = request.response 47 | 48 | response.headers['Allow'] = ', '.join(sorted(methods)) 49 | 50 | if 'Access-Control-Request-Method' in request.headers: 51 | response.headers['Access-Control-Allow-Methods'] = \ 52 | ', '.join(sorted(methods)) 53 | 54 | if 'Access-Control-Request-Headers' in request.headers: 55 | response.headers['Access-Control-Allow-Headers'] = \ 56 | 'origin, x-requested-with, content-type' 57 | 58 | return response 59 | 60 | def _get_handled_methods(self, actions_map): 61 | """ Get names of HTTP methods that can be used at requested URI. 62 | 63 | Arguments: 64 | :actions_map: Map of actions. Must have the same structure as 65 | self._item_actions and self._collection_actions 66 | """ 67 | methods = ('OPTIONS',) 68 | 69 | defined_actions = [] 70 | for action_name in actions_map.keys(): 71 | view_method = getattr(self, action_name, None) 72 | method_exists = view_method is not None 73 | method_defined = view_method != self.not_allowed_action 74 | if method_exists and method_defined: 75 | defined_actions.append(action_name) 76 | 77 | for action in defined_actions: 78 | methods += actions_map[action] 79 | 80 | return methods 81 | 82 | def item_options(self, **kwargs): 83 | """ Handle collection OPTIONS request. 84 | 85 | Singular route requests are handled a bit differently because 86 | singular views may handle POST requests despite being registered 87 | as item routes. 88 | """ 89 | actions = self._item_actions.copy() 90 | if self._resource.is_singular: 91 | actions['create'] = ('POST',) 92 | methods = self._get_handled_methods(actions) 93 | return self._set_options_headers(methods) 94 | 95 | def collection_options(self, **kwargs): 96 | """ Handle collection item OPTIONS request. """ 97 | methods = self._get_handled_methods(self._collection_actions) 98 | return self._set_options_headers(methods) 99 | 100 | 101 | class ESAggregator(object): 102 | """ Provides methods to perform Elasticsearch aggregations. 103 | 104 | Example of using ESAggregator: 105 | >> # Create an instance with view 106 | >> aggregator = ESAggregator(view) 107 | >> # Replace view.index with wrapped version 108 | >> view.index = aggregator.wrap(view.index) 109 | 110 | Attributes: 111 | :_aggregations_keys: Sequence of strings representing name(s) of the 112 | root key under which aggregations names are defined. Order of keys 113 | matters - first key found in request is popped and returned. May be 114 | overriden by defining it on view. 115 | 116 | Examples: 117 | If _aggregations_keys=('_aggregations',), then query string params 118 | should look like: 119 | _aggregations.min_price.min.field=price 120 | """ 121 | _aggregations_keys = ('_aggregations', '_aggs') 122 | 123 | def __init__(self, view): 124 | self.view = view 125 | view_aggregations_keys = getattr(view, '_aggregations_keys', None) 126 | if view_aggregations_keys: 127 | self._aggregations_keys = view_aggregations_keys 128 | 129 | def wrap(self, func): 130 | """ Wrap :func: to perform aggregation on :func: call. 131 | 132 | Should be called with view instance methods. 133 | """ 134 | @six.wraps(func) 135 | def wrapper(*args, **kwargs): 136 | try: 137 | return self.aggregate() 138 | except KeyError: 139 | return func(*args, **kwargs) 140 | return wrapper 141 | 142 | def pop_aggregations_params(self): 143 | """ Pop and return aggregation params from query string params. 144 | 145 | Aggregation params are expected to be prefixed(nested under) by 146 | any of `self._aggregations_keys`. 147 | """ 148 | from nefertari.view import BaseView 149 | self._query_params = BaseView.convert_dotted(self.view._query_params) 150 | 151 | for key in self._aggregations_keys: 152 | if key in self._query_params: 153 | return self._query_params.pop(key) 154 | else: 155 | raise KeyError('Missing aggregation params') 156 | 157 | def stub_wrappers(self): 158 | """ Remove default 'index' after call wrappers and add only 159 | those needed for aggregation results output. 160 | """ 161 | self.view._after_calls['index'] = [] 162 | 163 | @classmethod 164 | def get_aggregations_fields(cls, params): 165 | """ Recursively get values under the 'field' key. 166 | 167 | Is used to get names of fields on which aggregations should be 168 | performed. 169 | """ 170 | fields = [] 171 | for key, val in params.items(): 172 | if isinstance(val, dict): 173 | fields += cls.get_aggregations_fields(val) 174 | if key == 'field': 175 | fields.append(val) 176 | return fields 177 | 178 | def check_aggregations_privacy(self, aggregations_params): 179 | """ Check per-field privacy rules in aggregations. 180 | 181 | Privacy is checked by making sure user has access to the fields 182 | used in aggregations. 183 | """ 184 | fields = self.get_aggregations_fields(aggregations_params) 185 | fields_dict = dictset.fromkeys(fields) 186 | fields_dict['_type'] = self.view.Model.__name__ 187 | 188 | try: 189 | validate_data_privacy(self.view.request, fields_dict) 190 | except wrappers.ValidationError as ex: 191 | raise JHTTPForbidden( 192 | 'Not enough permissions to aggregate on ' 193 | 'fields: {}'.format(ex)) 194 | 195 | def aggregate(self): 196 | """ Perform aggregation and return response. """ 197 | from nefertari.elasticsearch import ES 198 | aggregations_params = self.pop_aggregations_params() 199 | if self.view._auth_enabled: 200 | self.check_aggregations_privacy(aggregations_params) 201 | self.stub_wrappers() 202 | 203 | return ES(self.view.Model.__name__).aggregate( 204 | _aggregations_params=aggregations_params, 205 | **self._query_params) 206 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Nefertari.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Nefertari.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Nefertari" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Nefertari" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /nefertari/authentication/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import logging 3 | 4 | import cryptacular.bcrypt 5 | from pyramid.security import authenticated_userid, forget 6 | 7 | from nefertari.json_httpexceptions import JHTTPBadRequest 8 | from nefertari import engine 9 | from nefertari.utils import dictset 10 | 11 | log = logging.getLogger(__name__) 12 | crypt = cryptacular.bcrypt.BCRYPTPasswordManager() 13 | 14 | 15 | class AuthModelMethodsMixin(object): 16 | """ Mixin that implements all methods required for Ticket and Token 17 | auth systems to work. 18 | 19 | All implemented methods must be class methods. 20 | """ 21 | @classmethod 22 | def get_item(self, *args, **kwargs): 23 | return super(AuthModelMethodsMixin, self).get_item( 24 | *args, **kwargs) 25 | 26 | @classmethod 27 | def pk_field(self, *args, **kwargs): 28 | return super(AuthModelMethodsMixin, self).pk_field(*args, **kwargs) 29 | 30 | @classmethod 31 | def get_or_create(self, *args, **kwargs): 32 | return super(AuthModelMethodsMixin, self).get_or_create( 33 | *args, **kwargs) 34 | 35 | @classmethod 36 | def is_admin(cls, user): 37 | """ Determine if :user: is an admin. Used by `apply_privacy` wrapper. 38 | """ 39 | return 'admin' in user.groups 40 | 41 | @classmethod 42 | def get_token_credentials(cls, username, request): 43 | """ Get api token for user with username of :username: 44 | 45 | Used by Token-based auth as `credentials_callback` kwarg. 46 | """ 47 | try: 48 | user = cls.get_item(username=username) 49 | except Exception as ex: 50 | log.error(str(ex)) 51 | forget(request) 52 | else: 53 | if user: 54 | return user.api_key.token 55 | 56 | @classmethod 57 | def get_groups_by_token(cls, username, token, request): 58 | """ Get user's groups if user with :username: exists and their api key 59 | token equals :token: 60 | 61 | Used by Token-based authentication as `check` kwarg. 62 | """ 63 | try: 64 | user = cls.get_item(username=username) 65 | except Exception as ex: 66 | log.error(str(ex)) 67 | forget(request) 68 | return 69 | else: 70 | if user and user.api_key.token == token: 71 | return ['g:%s' % g for g in user.groups] 72 | 73 | @classmethod 74 | def authenticate_by_password(cls, params): 75 | """ Authenticate user with login and password from :params: 76 | 77 | Used both by Token and Ticket-based auths (called from views). 78 | """ 79 | def verify_password(user, password): 80 | return crypt.check(user.password, password) 81 | 82 | success = False 83 | user = None 84 | login = params['login'].lower().strip() 85 | key = 'email' if '@' in login else 'username' 86 | try: 87 | user = cls.get_item(**{key: login}) 88 | except Exception as ex: 89 | log.error(str(ex)) 90 | 91 | if user: 92 | password = params.get('password', None) 93 | success = (password and verify_password(user, password)) 94 | return success, user 95 | 96 | @classmethod 97 | def get_groups_by_userid(cls, userid, request): 98 | """ Return group identifiers of user with id :userid: 99 | 100 | Used by Ticket-based auth as `callback` kwarg. 101 | """ 102 | try: 103 | cache_request_user(cls, request, userid) 104 | except Exception as ex: 105 | log.error(str(ex)) 106 | forget(request) 107 | else: 108 | if request._user: 109 | return ['g:%s' % g for g in request._user.groups] 110 | 111 | @classmethod 112 | def create_account(cls, params): 113 | """ Create auth user instance with data from :params:. 114 | 115 | Used by both Token and Ticket-based auths to register a user ( 116 | called from views). 117 | """ 118 | user_params = dictset(params).subset( 119 | ['username', 'email', 'password']) 120 | try: 121 | return cls.get_or_create( 122 | email=user_params['email'], 123 | defaults=user_params) 124 | except JHTTPBadRequest: 125 | raise JHTTPBadRequest('Failed to create account.') 126 | 127 | @classmethod 128 | def get_authuser_by_userid(cls, request): 129 | """ Get user by ID. 130 | 131 | Used by Ticket-based auth. Is added as request method to populate 132 | `request.user`. 133 | """ 134 | userid = authenticated_userid(request) 135 | if userid: 136 | cache_request_user(cls, request, userid) 137 | return request._user 138 | 139 | @classmethod 140 | def get_authuser_by_name(cls, request): 141 | """ Get user by username 142 | 143 | Used by Token-based auth. Is added as request method to populate 144 | `request.user`. 145 | """ 146 | username = authenticated_userid(request) 147 | if username: 148 | return cls.get_item(username=username) 149 | 150 | 151 | def lower_strip(**kwargs): 152 | return (kwargs['new_value'] or '').lower().strip() 153 | 154 | 155 | def random_uuid(**kwargs): 156 | return kwargs['new_value'] or uuid.uuid4().hex 157 | 158 | 159 | def encrypt_password(**kwargs): 160 | """ Crypt :new_value: if it's not crypted yet. """ 161 | new_value = kwargs['new_value'] 162 | field = kwargs['field'] 163 | min_length = field.params['min_length'] 164 | if len(new_value) < min_length: 165 | raise ValueError( 166 | '`{}`: Value length must be more than {}'.format( 167 | field.name, field.params['min_length'])) 168 | 169 | if new_value and not crypt.match(new_value): 170 | new_value = str(crypt.encode(new_value)) 171 | return new_value 172 | 173 | 174 | class AuthUserMixin(AuthModelMethodsMixin): 175 | """ Mixin that may be used as base for auth User models. 176 | 177 | Implements basic operations to support Pyramid Ticket-based and custom 178 | ApiKey token-based authentication. 179 | """ 180 | username = engine.StringField( 181 | primary_key=True, unique=True, 182 | min_length=1, max_length=50) 183 | email = engine.StringField(unique=True, required=True) 184 | password = engine.StringField(min_length=3, required=True) 185 | groups = engine.ListField( 186 | item_type=engine.StringField, 187 | choices=['admin', 'user'], default=['user']) 188 | 189 | 190 | def create_apikey_token(): 191 | """ Generate ApiKey.token using uuid library. """ 192 | return uuid.uuid4().hex.replace('-', '') 193 | 194 | 195 | def create_apikey_model(user_model): 196 | """ Generate ApiKey model class and connect it with :user_model:. 197 | 198 | ApiKey is generated with relationship to user model class :user_model: 199 | as a One-to-One relationship with a backreference. 200 | ApiKey is set up to be auto-generated when a new :user_model: is created. 201 | 202 | Returns ApiKey document class. If ApiKey is already defined, it is not 203 | generated. 204 | 205 | Arguments: 206 | :user_model: Class that represents user model for which api keys will 207 | be generated and with which ApiKey will have relationship. 208 | """ 209 | try: 210 | return engine.get_document_cls('ApiKey') 211 | except ValueError: 212 | pass 213 | 214 | fk_kwargs = { 215 | 'ref_column': None, 216 | } 217 | if hasattr(user_model, '__tablename__'): 218 | fk_kwargs['ref_column'] = '.'.join([ 219 | user_model.__tablename__, user_model.pk_field()]) 220 | fk_kwargs['ref_column_type'] = user_model.pk_field_type() 221 | 222 | class ApiKey(engine.BaseDocument): 223 | __tablename__ = 'nefertari_apikey' 224 | 225 | id = engine.IdField(primary_key=True) 226 | token = engine.StringField(default=create_apikey_token) 227 | user = engine.Relationship( 228 | document=user_model.__name__, 229 | uselist=False, 230 | backref_name='api_key', 231 | backref_uselist=False) 232 | user_id = engine.ForeignKeyField( 233 | ref_document=user_model.__name__, 234 | **fk_kwargs) 235 | 236 | def reset_token(self): 237 | self.update({'token': create_apikey_token()}) 238 | return self.token 239 | 240 | # Setup ApiKey autogeneration on :user_model: creation 241 | ApiKey.autogenerate_for(user_model, 'user') 242 | 243 | return ApiKey 244 | 245 | 246 | def cache_request_user(user_cls, request, user_id): 247 | """ Helper function to cache currently logged in user. 248 | 249 | User is cached at `request._user`. Caching happens only only 250 | if user is not already cached or if cached user's pk does not 251 | match `user_id`. 252 | 253 | :param user_cls: User model class to use for user lookup. 254 | :param request: Pyramid Request instance. 255 | :user_id: Current user primary key field value. 256 | """ 257 | pk_field = user_cls.pk_field() 258 | user = getattr(request, '_user', None) 259 | if user is None or getattr(user, pk_field, None) != user_id: 260 | request._user = user_cls.get_item(**{pk_field: user_id}) 261 | -------------------------------------------------------------------------------- /tests/test_view_helpers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from mock import Mock, patch 3 | 4 | from nefertari.view import BaseView 5 | from nefertari.utils import dictset 6 | from nefertari.view_helpers import ESAggregator 7 | from nefertari.json_httpexceptions import JHTTPForbidden 8 | 9 | 10 | class DemoView(BaseView): 11 | """ BaseView inherits from OptionsViewMixin """ 12 | _json_encoder = 'Foo' 13 | 14 | def create(self): 15 | pass 16 | 17 | def index(self, **kwargs): 18 | pass 19 | 20 | def update(self): 21 | pass 22 | 23 | def delete(self): 24 | pass 25 | 26 | 27 | class TestOptionsViewMixin(object): 28 | 29 | def _demo_view(self): 30 | return DemoView(**{ 31 | 'request': Mock( 32 | content_type='application/json', 33 | json={}, method='POST', accept=[''], 34 | headers={}, response=Mock(headers={})), 35 | 'context': {'foo': 'bar'}, 36 | '_query_params': {'foo': 'bar'}, 37 | '_json_params': {'foo': 'bar'}, 38 | }) 39 | 40 | def test_set_options_headers(self): 41 | view = self._demo_view() 42 | view.request.headers = { 43 | 'Access-Control-Request-Method': '', 44 | 'Access-Control-Request-Headers': '', 45 | } 46 | view._set_options_headers(['GET', 'POST']) 47 | assert view.request.response.headers == { 48 | 'Access-Control-Allow-Headers': 'origin, x-requested-with, content-type', 49 | 'Access-Control-Allow-Methods': 'GET, POST', 50 | 'Allow': 'GET, POST', 51 | } 52 | 53 | def test_get_handled_methods(self): 54 | view = self._demo_view() 55 | methods = view._get_handled_methods(view._item_actions) 56 | assert sorted(methods) == sorted(['DELETE', 'OPTIONS', 'PATCH']) 57 | methods = view._get_handled_methods(view._collection_actions) 58 | assert sorted(methods) == sorted([ 59 | 'GET', 'HEAD', 'OPTIONS', 'POST']) 60 | 61 | def test_item_options_singular(self): 62 | view = self._demo_view() 63 | expected_actions = view._item_actions.copy() 64 | expected_actions['create'] = ('POST',) 65 | view._get_handled_methods = Mock(return_value=['GET', 'POST']) 66 | view._set_options_headers = Mock(return_value=1) 67 | view._resource = Mock(is_singular=True) 68 | assert view.item_options() == 1 69 | view._get_handled_methods.assert_called_once_with( 70 | expected_actions) 71 | view._set_options_headers.assert_called_once_with( 72 | ['GET', 'POST']) 73 | 74 | def test_item_options_not_singular(self): 75 | view = self._demo_view() 76 | view._get_handled_methods = Mock(return_value=['GET', 'POST']) 77 | view._set_options_headers = Mock(return_value=1) 78 | view._resource = Mock(is_singular=False) 79 | assert view.item_options() == 1 80 | view._get_handled_methods.assert_called_once_with( 81 | view._item_actions) 82 | view._set_options_headers.assert_called_once_with( 83 | ['GET', 'POST']) 84 | 85 | def test_collection_options_not_singular(self): 86 | view = self._demo_view() 87 | view._get_handled_methods = Mock(return_value=['GET', 'POST']) 88 | view._set_options_headers = Mock(return_value=1) 89 | assert view.collection_options() == 1 90 | view._get_handled_methods.assert_called_once_with( 91 | view._collection_actions) 92 | view._set_options_headers.assert_called_once_with( 93 | ['GET', 'POST']) 94 | 95 | 96 | class TestESAggregator(object): 97 | 98 | class DemoView(object): 99 | _aggregations_keys = ('test_aggregations',) 100 | _query_params = dictset() 101 | _json_params = dictset() 102 | 103 | def test_pop_aggregations_params_query_string(self): 104 | view = self.DemoView() 105 | view._query_params = {'test_aggregations.foo': 1, 'bar': 2} 106 | aggregator = ESAggregator(view) 107 | params = aggregator.pop_aggregations_params() 108 | assert params == {'foo': 1} 109 | assert aggregator._query_params == {'bar': 2} 110 | 111 | def test_pop_aggregations_params_keys_order(self): 112 | view = self.DemoView() 113 | view._query_params = { 114 | 'test_aggregations.foo': 1, 115 | 'foobar': 2, 116 | } 117 | aggregator = ESAggregator(view) 118 | aggregator._aggregations_keys = ('test_aggregations', 'foobar') 119 | params = aggregator.pop_aggregations_params() 120 | assert params == {'foo': 1} 121 | assert aggregator._query_params == {'foobar': 2} 122 | 123 | def test_pop_aggregations_params_mey_error(self): 124 | view = self.DemoView() 125 | aggregator = ESAggregator(view) 126 | with pytest.raises(KeyError) as ex: 127 | aggregator.pop_aggregations_params() 128 | assert 'Missing aggregation params' in str(ex.value) 129 | 130 | def test_stub_wrappers(self): 131 | view = self.DemoView() 132 | view._after_calls = {'index': [1, 2, 3], 'show': [1, 2]} 133 | aggregator = ESAggregator(view) 134 | aggregator.stub_wrappers() 135 | assert aggregator.view._after_calls == {'show': [1, 2], 'index': []} 136 | 137 | @patch('nefertari.elasticsearch.ES') 138 | def test_aggregate(self, mock_es): 139 | view = self.DemoView() 140 | view._auth_enabled = True 141 | view.Model = Mock(__name__='FooBar') 142 | aggregator = ESAggregator(view) 143 | aggregator.check_aggregations_privacy = Mock() 144 | aggregator.stub_wrappers = Mock() 145 | aggregator.pop_aggregations_params = Mock(return_value={'foo': 1}) 146 | aggregator._query_params = {'q': '2', 'zoo': 3} 147 | aggregator.aggregate() 148 | aggregator.stub_wrappers.assert_called_once_with() 149 | aggregator.pop_aggregations_params.assert_called_once_with() 150 | aggregator.check_aggregations_privacy.assert_called_once_with( 151 | {'foo': 1}) 152 | mock_es.assert_called_once_with('FooBar') 153 | mock_es().aggregate.assert_called_once_with( 154 | _aggregations_params={'foo': 1}, 155 | q='2', zoo=3) 156 | 157 | def test_get_aggregations_fields(self): 158 | params = { 159 | 'min': {'field': 'foo'}, 160 | 'histogram': {'field': 'bar', 'interval': 10}, 161 | 'aggregations': { 162 | 'my_agg': { 163 | 'max': {'field': 'baz'} 164 | } 165 | } 166 | } 167 | result = sorted(ESAggregator.get_aggregations_fields(params)) 168 | assert result == sorted(['foo', 'bar', 'baz']) 169 | 170 | @patch('nefertari.wrappers.apply_privacy') 171 | def test_check_aggregations_privacy_all_allowed(self, mock_privacy): 172 | view = self.DemoView() 173 | view.request = 1 174 | view.Model = Mock(__name__='Zoo') 175 | aggregator = ESAggregator(view) 176 | aggregator.get_aggregations_fields = Mock(return_value=['foo', 'bar']) 177 | wrapper = Mock() 178 | mock_privacy.return_value = wrapper 179 | wrapper.return_value = {'foo': None, 'bar': None} 180 | try: 181 | aggregator.check_aggregations_privacy({'zoo': 2}) 182 | except JHTTPForbidden: 183 | raise Exception('Unexpected error') 184 | aggregator.get_aggregations_fields.assert_called_once_with({'zoo': 2}) 185 | mock_privacy.assert_called_once_with(1) 186 | wrapper.assert_called_once_with( 187 | result={'_type': 'Zoo', 'foo': None, 'bar': None}) 188 | 189 | @patch('nefertari.wrappers.apply_privacy') 190 | def test_check_aggregations_privacy_not_allowed(self, mock_privacy): 191 | view = self.DemoView() 192 | view.request = 1 193 | view.Model = Mock(__name__='Zoo') 194 | aggregator = ESAggregator(view) 195 | aggregator.get_aggregations_fields = Mock(return_value=['foo', 'bar']) 196 | wrapper = Mock() 197 | mock_privacy.return_value = wrapper 198 | wrapper.return_value = {'bar': None} 199 | with pytest.raises(JHTTPForbidden) as ex: 200 | aggregator.check_aggregations_privacy({'zoo': 2}) 201 | expected = 'Not enough permissions to aggregate on fields: foo' 202 | assert expected == str(ex.value) 203 | aggregator.get_aggregations_fields.assert_called_once_with({'zoo': 2}) 204 | mock_privacy.assert_called_once_with(1) 205 | wrapper.assert_called_once_with( 206 | result={'_type': 'Zoo', 'foo': None, 'bar': None}) 207 | 208 | def view_aggregations_keys_used(self): 209 | view = self.DemoView() 210 | view._aggregations_keys = ('foo',) 211 | assert ESAggregator(view)._aggregations_keys == ('foo',) 212 | view._aggregations_keys = None 213 | assert ESAggregator(view)._aggregations_keys == ( 214 | '_aggregations', '_aggs') 215 | 216 | def test_wrap(self): 217 | view = self.DemoView() 218 | view.index = Mock(__name__='foo') 219 | aggregator = ESAggregator(view) 220 | aggregator.aggregate = Mock(side_effect=KeyError) 221 | func = aggregator.wrap(view.index) 222 | func(1, 2) 223 | aggregator.aggregate.assert_called_once_with() 224 | view.index.assert_called_once_with(1, 2) 225 | -------------------------------------------------------------------------------- /tests/test_events.py: -------------------------------------------------------------------------------- 1 | from mock import patch, Mock, call 2 | 3 | from nefertari import events 4 | 5 | 6 | class TestEvents(object): 7 | def test_request_event_init(self): 8 | obj = events.RequestEvent( 9 | view=1, model=2, fields=3, field=4, instance=5) 10 | assert obj.view == 1 11 | assert obj.model == 2 12 | assert obj.fields == 3 13 | assert obj.field == 4 14 | assert obj.instance == 5 15 | 16 | def test_before_event_set_field_value_field_present(self): 17 | view = Mock(_json_params={}) 18 | event = events.BeforeEvent( 19 | view=view, model=None, field=None, 20 | fields={}) 21 | event.fields['foo'] = Mock() 22 | event.set_field_value('foo', 2) 23 | assert view._json_params == {'foo': 2} 24 | assert event.fields['foo'].new_value == 2 25 | 26 | @patch('nefertari.events.FieldData') 27 | def test_before_event_set_field_value_field_not_present(self, mock_field): 28 | mock_field.from_dict.return_value = {'q': 1} 29 | view = Mock(_json_params={}) 30 | event = events.BeforeEvent( 31 | view=view, model=None, field=None, 32 | fields={}) 33 | assert 'foo' not in event.fields 34 | event.set_field_value('foo', 2) 35 | assert view._json_params == {'foo': 2} 36 | mock_field.from_dict.assert_called_once_with( 37 | {'foo': 2}, event.model) 38 | assert event.fields == {'q': 1} 39 | 40 | def test_after_event_set_field_value_none_resp(self): 41 | view = Mock(_json_params={}) 42 | event = events.AfterEvent( 43 | model=None, view=view) 44 | event.set_field_value('foo', 3) 45 | assert event.response is None 46 | 47 | def test_after_event_set_field_value_single_item(self): 48 | view = Mock(_json_params={}) 49 | event = events.AfterEvent( 50 | model=None, view=view, 51 | response={'foo': 1, 'bar': 2}) 52 | event.set_field_value('foo', 3) 53 | assert event.response == {'foo': 3, 'bar': 2} 54 | 55 | def test_after_event_set_field_value_collection(self): 56 | view = Mock(_json_params={}) 57 | event = events.AfterEvent( 58 | model=None, view=view, 59 | response={'data': [ 60 | {'foo': 1, 'bar': 4}, 61 | {'foo': 1, 'bar': 5}, 62 | ]} 63 | ) 64 | event.set_field_value('foo', 3) 65 | assert len(event.response['data']) == 2 66 | assert {'foo': 3, 'bar': 4} in event.response['data'] 67 | assert {'foo': 3, 'bar': 5} in event.response['data'] 68 | 69 | 70 | class TestHelperFunctions(object): 71 | def test_get_event_kwargs_no_trigger(self): 72 | view = Mock(index=Mock(_silent=True), _silent=True) 73 | view.request = Mock(action='index') 74 | assert events._get_event_kwargs(view) is None 75 | 76 | @patch('nefertari.events.FieldData') 77 | def test_get_event_kwargs(self, mock_fd): 78 | view = Mock(index=Mock(_silent=False), _silent=False) 79 | view.request = Mock(action='index') 80 | kwargs = events._get_event_kwargs(view) 81 | mock_fd.from_dict.assert_called_once_with( 82 | view._json_params, view.Model) 83 | assert kwargs == { 84 | 'fields': mock_fd.from_dict(), 85 | 'instance': view.context, 86 | 'model': view.Model, 87 | 'view': view 88 | } 89 | 90 | def test_get_event_cls_event_action(self): 91 | index = Mock(_event_action='index') 92 | request = Mock(action='index') 93 | view = Mock(request=request, index=index) 94 | evt = events._get_event_cls(view, events.BEFORE_EVENTS) 95 | assert evt is events.BeforeIndex 96 | 97 | def test_get_event_cls(self): 98 | index = Mock(_event_action=None) 99 | request = Mock(action='index') 100 | view = Mock(request=request, index=index) 101 | evt = events._get_event_cls(view, events.AFTER_EVENTS) 102 | assert evt is events.AfterIndex 103 | 104 | @patch('nefertari.events._get_event_cls') 105 | @patch('nefertari.events._get_event_kwargs') 106 | def test_trigger_events_no_kw(self, mock_kw, mock_cls): 107 | mock_cls.return_value = events.AfterIndex 108 | view = Mock() 109 | mock_kw.return_value = None 110 | events._trigger_events(view, events.AFTER_EVENTS) 111 | assert not mock_cls.called 112 | mock_kw.assert_called_once_with(view) 113 | 114 | @patch('nefertari.events._get_event_cls') 115 | @patch('nefertari.events._get_event_kwargs') 116 | def test_trigger_events(self, mock_kw, mock_cls): 117 | view = Mock() 118 | mock_kw.return_value = {'foo': 1} 119 | res = events._trigger_events(view, events.AFTER_EVENTS, {'bar': 2}) 120 | mock_kw.assert_called_once_with(view) 121 | mock_cls.assert_called_once_with(view, events.AFTER_EVENTS) 122 | evt = mock_cls() 123 | evt.assert_called_once_with(foo=1, bar=2) 124 | view.request.registry.notify.assert_called_once_with(evt()) 125 | assert res == evt() 126 | 127 | @patch('nefertari.events._trigger_events') 128 | def test_trigger_before_events(self, mock_trig): 129 | view = Mock() 130 | res = events.trigger_before_events(view) 131 | mock_trig.assert_called_once_with(view, events.BEFORE_EVENTS) 132 | assert res == mock_trig() 133 | 134 | @patch('nefertari.events._trigger_events') 135 | def test_trigger_after_events(self, mock_trig): 136 | view = Mock() 137 | res = events.trigger_after_events(view) 138 | mock_trig.assert_called_once_with( 139 | view, events.AFTER_EVENTS, {'response': view._response}) 140 | assert res == mock_trig() 141 | 142 | def test_subscribe_to_events(self): 143 | config = Mock() 144 | events.subscribe_to_events( 145 | config, 'foo', [1, 2], model=3) 146 | config.add_subscriber.assert_has_calls([ 147 | call('foo', 1, model=3), 148 | call('foo', 2, model=3) 149 | ]) 150 | 151 | def test_silent_decorator(self): 152 | @events.silent 153 | def foo(): 154 | pass 155 | 156 | assert foo._silent 157 | 158 | @events.silent 159 | class Foo(object): 160 | pass 161 | 162 | assert Foo._silent 163 | 164 | def test_trigger_instead_decorator(self): 165 | @events.trigger_instead('foobar') 166 | def foo(): 167 | pass 168 | 169 | assert foo._event_action == 'foobar' 170 | 171 | def test_add_field_processors(self): 172 | event = Mock() 173 | event.field.new_value = 'admin' 174 | config = Mock() 175 | processor = Mock(return_value='user12') 176 | 177 | events.add_field_processors( 178 | config, [processor, processor], 179 | model='User', field='username') 180 | assert config.add_subscriber.call_count == 5 181 | assert not event.set_field_value.called 182 | assert not processor.called 183 | 184 | last_call = config.add_subscriber.mock_calls[0] 185 | wrapper = last_call[1][0] 186 | wrapper(event) 187 | event.set_field_value.assert_called_once_with( 188 | 'username', 'user12') 189 | assert event.field.new_value == 'user12' 190 | 191 | processor.assert_has_calls([ 192 | call(new_value='admin', instance=event.instance, 193 | field=event.field, request=event.view.request, 194 | model=event.model, event=event), 195 | call(new_value='user12', instance=event.instance, 196 | field=event.field, request=event.view.request, 197 | model=event.model, event=event), 198 | ]) 199 | 200 | 201 | class TestModelClassIs(object): 202 | def test_wrong_class(self): 203 | class A(object): 204 | pass 205 | 206 | predicate = events.ModelClassIs(model=A, config=None) 207 | event = events.BeforeIndex(view=None, model=list) 208 | assert not predicate(event) 209 | 210 | def test_correct_class(self): 211 | class A(object): 212 | pass 213 | 214 | predicate = events.ModelClassIs(model=A, config=None) 215 | event = events.BeforeIndex(view=None, model=A) 216 | assert predicate(event) 217 | 218 | def test_correct_subclass(self): 219 | class A(object): 220 | pass 221 | 222 | class B(A): 223 | pass 224 | 225 | predicate = events.ModelClassIs(model=A, config=None) 226 | event = events.BeforeIndex(view=None, model=B) 227 | assert predicate(event) 228 | 229 | 230 | class TestFieldIsChanged(object): 231 | def test_field_changed(self): 232 | predicate = events.FieldIsChanged(field='username', config=None) 233 | event = events.BeforeIndex( 234 | view=None, model=None, 235 | fields={'username': 'asd'}) 236 | assert event.field is None 237 | assert predicate(event) 238 | assert event.field == 'asd' 239 | 240 | def test_field_not_changed(self): 241 | predicate = events.FieldIsChanged(field='username', config=None) 242 | event = events.BeforeIndex( 243 | view=None, model=None, 244 | fields={'password': 'asd'}) 245 | assert event.field is None 246 | assert not predicate(event) 247 | assert event.field is None 248 | --------------------------------------------------------------------------------