├── .gitignore ├── .travis.yml ├── AUTHORS ├── LICENSE ├── README.rst ├── TODO.md ├── docs ├── Makefile ├── api.rst ├── conf.py ├── features.rst └── index.rst ├── eve_mongoengine ├── __init__.py ├── __version__.py ├── _compat.py ├── datalayer.py ├── schema.py ├── struct.py └── validation.py ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── test_delete.py ├── test_fields.py ├── test_get.py ├── test_mongoengine_fix.py ├── test_patch.py ├── test_post.py ├── test_put.py ├── test_queryset.py └── test_struct.py /.gitignore: -------------------------------------------------------------------------------- 1 | /.project 2 | /.pydevproject 3 | /.settings 4 | .ropeproject 5 | 6 | # Eve 7 | run.py 8 | settings.py 9 | 10 | # Python 11 | *.py[co] 12 | 13 | # Gedit 14 | *~ 15 | 16 | # Packages 17 | *.egg 18 | *.egg-info 19 | dist 20 | build 21 | eggs 22 | parts 23 | bin 24 | var 25 | sdist 26 | develop-eggs 27 | .installed.cfg 28 | .eggs 29 | 30 | # Installer logs 31 | pip-log.txt 32 | 33 | # Unit test / coverage reports 34 | .coverage 35 | .tox 36 | 37 | #Translations 38 | *.mo 39 | 40 | #Mr Developer 41 | .mr.developer.cfg 42 | 43 | # SublimeText project files 44 | *.sublime-* 45 | 46 | # vim temp files 47 | *.swp 48 | 49 | #virtualenv 50 | Include 51 | Lib 52 | Scripts 53 | 54 | #OSX 55 | .Python 56 | .DS_Store 57 | 58 | #Sphinx 59 | _build 60 | 61 | # PyCharm 62 | .idea -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | services: mongodb 3 | python: 4 | - '2.7' 5 | - '3.3' 6 | - '3.4' 7 | - pypy 8 | - pypy3 9 | env: 10 | - EVE=0.5.3 MONGOENGINE=0.8.7 11 | - EVE=0.5.3 MONGOENGINE=0.9 12 | - EVE=0.5.3 MONGOENGINE=dev 13 | - EVE=prod MONGOENGINE=0.8.7 14 | - EVE=prod MONGOENGINE=0.9 15 | - EVE=prod MONGOENGINE=dev 16 | - EVE=dev MONGOENGINE=0.8.7 17 | - EVE=dev MONGOENGINE=0.9 18 | - EVE=dev MONGOENGINE=dev 19 | matrix: 20 | allow_failures: 21 | - python: pypy3 22 | fast_finish: true 23 | install: 24 | - sudo apt-get install python-dev python3-dev 25 | - if [[ $EVE == 'dev' ]]; then travis_retry pip install https://github.com/nicolaiarocci/eve/tarball/develop; 26 | elif [[ $EVE == 'prod' ]]; then travis_retry pip install https://github.com/nicolaiarocci/eve/tarball/master; 27 | else travis_retry pip install eve==$EVE; 28 | fi 29 | - if [[ $MONGOENGINE == 'dev' ]]; then travis_retry pip install https://github.com/MongoEngine/mongoengine/tarball/master; 30 | else travis_retry pip install mongoengine==$MONGOENGINE; 31 | fi 32 | - travis_retry python setup.py install 33 | script: 34 | - travis_retry python setup.py test 35 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | The PRIMARY AUTHORS are (or have previously been): 2 | 3 | * Stanislav Heller 4 | * Matthew Ellison 5 | 6 | 7 | CONTRIBUTORS under the BSD-2 License prior to 8 | version 0.1: 9 | 10 | * Manfred Touron 11 | * Kelly Caylor 12 | * Dmitry / anoshindx@gmail.com 13 | 14 | 15 | CONTRIBUTORS 16 | 17 | The below list is not authoratative nor considered 18 | a complete and comprehensive list. The git logs 19 | are considered the authoratative list of contributors. 20 | 21 | Below is a list of individuals whom have submitted 22 | patches, reported bugs, and helped to improve the 23 | project: 24 | 25 | 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 See AUTHORS 2 | 3 | Versions and contributions released prior to v0.1 were 4 | released under the BSD-2 license. 5 | Please download a previous version of the project to 6 | view the BSD-2 license used. 7 | 8 | Permission is hereby granted, free of charge, to any person 9 | obtaining a copy of this software and associated documentation 10 | files (the "Software"), to deal in the Software without 11 | restriction, including without limitation the rights to use, 12 | copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the 14 | Software is furnished to do so, subject to the following 15 | conditions: 16 | 17 | The above copyright notice and this permission notice shall be 18 | included in all copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 21 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 22 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 23 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 24 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 25 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 26 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 27 | OTHER DEALINGS IN THE SOFTWARE. 28 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Eve-MongoEngine 3 | =============== 4 | :Info: Eve-MongoEngine provides MongoEngine integration with `Eve `_. 5 | :Repository: https://github.com/hellerstanislav/eve-mongoengine 6 | :Author: Stanislav Heller (https://github.com/hellerstanislav) 7 | :Maintainer: Matthew Ellison (https://github.com/seglberg) 8 | 9 | ---- 10 | 11 | .. |travis-master| image:: https://api.travis-ci.org/seglberg/eve-mongoengine.png?branch=master 12 | :target: https://travis-ci.org/seglberg/eve-mongoengine 13 | 14 | .. |travis-develop| image:: https://api.travis-ci.org/seglberg/eve-mongoengine.png?branch=develop 15 | :target: https://travis-ci.org/seglberg/eve-mongoengine/branches 16 | 17 | .. |landscape-master| image:: https://landscape.io/github/seglberg/eve-mongoengine/master/landscape.svg?style=flat 18 | :target: https://landscape.io/github/seglberg/eve-mongoengine/master 19 | :alt: Code Health 20 | 21 | .. |landscape-develop| image:: https://landscape.io/github/seglberg/eve-mongoengine/develop/landscape.svg?style=flat 22 | :target: https://landscape.io/github/seglberg/eve-mongoengine/develop 23 | :alt: Code Health 24 | 25 | .. list-table:: 26 | :widths: 50 50 27 | :header-rows: 1 28 | 29 | * - Production 30 | - Development 31 | * - |travis-master| 32 | - |travis-develop| 33 | * - |landscape-master| 34 | - |landscape-develop| 35 | 36 | 37 | **THE DEVELOP BRANCH CONTAINS POST-LEGACY WORK** 38 | 39 | See the `legacy` tag for the last legacy release (0.0.10). 40 | 41 | Do not use `develop` in production code. Instead, the `master` branch always points to the latest production release, and should be used instead. 42 | 43 | 44 | About 45 | ===== 46 | 47 | Eve-MongoEngine is an `Eve`_ extension, which enables MongoEngine ODM model to be used as an Eve / `Cerberus `_ schema. This eliminates the need to re-write API schemas in the `Cerberus`_ format by using MongoEngine models to automatically export a corresponding Cerberus schema. 48 | 49 | Additional documentation and examples can be found on `Read the Docs `_. 50 | 51 | Installation 52 | ============ 53 | 54 | If you have ``pip`` installed you can use ``pip install eve-mongoengine``. Otherwise, you can download the 55 | source from `GitHub `_ and run ``python 56 | setup.py install``. 57 | 58 | Dependencies 59 | ============ 60 | 61 | - Python 2.7+ or Python 3.3+ 62 | 63 | - eve>=0.5.3 64 | - mongoengine>=0.8.7,<=0.9 65 | - blinker 66 | 67 | 68 | Optional Dependencies 69 | --------------------- 70 | 71 | - *None* 72 | 73 | Legacy Release 74 | ============== 75 | 76 | The legacy version of the extension can be found under the 'legacy' tag. 77 | The legacy version of the extension was released under the BSD-2 license and originally authored by Stanislav Heller. See AUTHORS for more information about the legacy authors and ownership. 78 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | * support DynamicField (there is one test for this, but definetely not enough) 2 | * pagination tests 3 | * save() and update() hooks on mongoengine classes: auto-generate etag and 4 | value of 'updated' field when performing actions on mongoengine side. 5 | * Documentation: advanced usage, extending and hacking 6 | * Test for subresources: PUT, PATCH, DELETE. Should be working, but tests missing. 7 | -------------------------------------------------------------------------------- /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) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 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 " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Eve-Mongoengine.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Eve-Mongoengine.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Eve-Mongoengine" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Eve-Mongoengine" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | 2 | API Documentation 3 | ----------------- 4 | 5 | .. automodule:: eve_mongoengine.__init__ 6 | :members: 7 | :undoc-members: 8 | :show-inheritance: 9 | 10 | 11 | .. automodule:: eve_mongoengine.schema 12 | :members: 13 | :undoc-members: 14 | :show-inheritance: 15 | 16 | 17 | .. automodule:: eve_mongoengine.validation 18 | :members: 19 | :undoc-members: 20 | :show-inheritance: 21 | 22 | .. automodule:: eve_mongoengine.struct 23 | :members: 24 | :undoc-members: 25 | :show-inheritance: 26 | 27 | .. automodule:: eve_mongoengine.datalayer 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | 32 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Eve-Mongoengine documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Nov 1 09:05:09 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | sys.path.insert(0, os.path.abspath('../')) 22 | 23 | # -- General configuration ------------------------------------------------ 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | #needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 30 | # ones. 31 | extensions = ['sphinx.ext.todo', 'sphinx.ext.viewcode', 'sphinx.ext.autodoc'] 32 | 33 | # Add any paths that contain templates here, relative to this directory. 34 | templates_path = ['_templates'] 35 | 36 | # The suffix of source filenames. 37 | source_suffix = '.rst' 38 | 39 | # The encoding of source files. 40 | #source_encoding = 'utf-8-sig' 41 | 42 | # The master toctree document. 43 | master_doc = 'index' 44 | 45 | # General information about the project. 46 | project = u'Eve-Mongoengine' 47 | copyright = u'2014, Stanislav Heller' 48 | 49 | # The version info for the project you're documenting, acts as replacement for 50 | # |version| and |release|, also used in various other places throughout the 51 | # built documents. 52 | # 53 | # The short X.Y version. 54 | version = '0.0.8' 55 | # The full version, including alpha/beta/rc tags. 56 | release = '0.0.8' 57 | 58 | # The language for content autogenerated by Sphinx. Refer to documentation 59 | # for a list of supported languages. 60 | #language = None 61 | 62 | # There are two options for replacing |today|: either, you set today to some 63 | # non-false value, then it is used: 64 | #today = '' 65 | # Else, today_fmt is used as the format for a strftime call. 66 | #today_fmt = '%B %d, %Y' 67 | 68 | # List of patterns, relative to source directory, that match files and 69 | # directories to ignore when looking for source files. 70 | exclude_patterns = ['_build'] 71 | 72 | # The reST default role (used for this markup: `text`) to use for all 73 | # documents. 74 | #default_role = None 75 | 76 | # If true, '()' will be appended to :func: etc. cross-reference text. 77 | #add_function_parentheses = True 78 | 79 | # If true, the current module name will be prepended to all description 80 | # unit titles (such as .. function::). 81 | #add_module_names = True 82 | 83 | # If true, sectionauthor and moduleauthor directives will be shown in the 84 | # output. They are ignored by default. 85 | #show_authors = False 86 | 87 | # The name of the Pygments (syntax highlighting) style to use. 88 | pygments_style = 'sphinx' 89 | 90 | # A list of ignored prefixes for module index sorting. 91 | #modindex_common_prefix = [] 92 | 93 | # If true, keep warnings as "system message" paragraphs in the built documents. 94 | #keep_warnings = False 95 | 96 | 97 | # -- Options for HTML output ---------------------------------------------- 98 | 99 | # The theme to use for HTML and HTML Help pages. See the documentation for 100 | # a list of builtin themes. 101 | html_theme = 'default' 102 | 103 | # Theme options are theme-specific and customize the look and feel of a theme 104 | # further. For a list of options available for each theme, see the 105 | # documentation. 106 | #html_theme_options = {} 107 | 108 | # Add any paths that contain custom themes here, relative to this directory. 109 | #html_theme_path = [] 110 | 111 | # The name for this set of Sphinx documents. If None, it defaults to 112 | # " v documentation". 113 | #html_title = None 114 | 115 | # A shorter title for the navigation bar. Default is the same as html_title. 116 | #html_short_title = None 117 | 118 | # The name of an image file (relative to this directory) to place at the top 119 | # of the sidebar. 120 | #html_logo = None 121 | 122 | # The name of an image file (within the static path) to use as favicon of the 123 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 124 | # pixels large. 125 | #html_favicon = None 126 | 127 | # Add any paths that contain custom static files (such as style sheets) here, 128 | # relative to this directory. They are copied after the builtin static files, 129 | # so a file named "default.css" will overwrite the builtin "default.css". 130 | html_static_path = ['_static'] 131 | 132 | # Add any extra paths that contain custom files (such as robots.txt or 133 | # .htaccess) here, relative to this directory. These files are copied 134 | # directly to the root of the documentation. 135 | #html_extra_path = [] 136 | 137 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 138 | # using the given strftime format. 139 | #html_last_updated_fmt = '%b %d, %Y' 140 | 141 | # If true, SmartyPants will be used to convert quotes and dashes to 142 | # typographically correct entities. 143 | #html_use_smartypants = True 144 | 145 | # Custom sidebar templates, maps document names to template names. 146 | #html_sidebars = {} 147 | 148 | # Additional templates that should be rendered to pages, maps page names to 149 | # template names. 150 | #html_additional_pages = {} 151 | 152 | # If false, no module index is generated. 153 | #html_domain_indices = True 154 | 155 | # If false, no index is generated. 156 | #html_use_index = True 157 | 158 | # If true, the index is split into individual pages for each letter. 159 | #html_split_index = False 160 | 161 | # If true, links to the reST sources are added to the pages. 162 | #html_show_sourcelink = True 163 | 164 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 165 | #html_show_sphinx = True 166 | 167 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 168 | #html_show_copyright = True 169 | 170 | # If true, an OpenSearch description file will be output, and all pages will 171 | # contain a tag referring to it. The value of this option must be the 172 | # base URL from which the finished HTML is served. 173 | #html_use_opensearch = '' 174 | 175 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 176 | #html_file_suffix = None 177 | 178 | # Output file base name for HTML help builder. 179 | htmlhelp_basename = 'Eve-Mongoenginedoc' 180 | 181 | 182 | # -- Options for LaTeX output --------------------------------------------- 183 | 184 | latex_elements = { 185 | # The paper size ('letterpaper' or 'a4paper'). 186 | #'papersize': 'letterpaper', 187 | 188 | # The font size ('10pt', '11pt' or '12pt'). 189 | #'pointsize': '10pt', 190 | 191 | # Additional stuff for the LaTeX preamble. 192 | #'preamble': '', 193 | } 194 | 195 | # Grouping the document tree into LaTeX files. List of tuples 196 | # (source start file, target name, title, 197 | # author, documentclass [howto, manual, or own class]). 198 | latex_documents = [ 199 | ('index', 'Eve-Mongoengine.tex', u'Eve-Mongoengine Documentation', 200 | u'Stanislav Heller', 'manual'), 201 | ] 202 | 203 | # The name of an image file (relative to this directory) to place at the top of 204 | # the title page. 205 | #latex_logo = None 206 | 207 | # For "manual" documents, if this is true, then toplevel headings are parts, 208 | # not chapters. 209 | #latex_use_parts = False 210 | 211 | # If true, show page references after internal links. 212 | #latex_show_pagerefs = False 213 | 214 | # If true, show URL addresses after external links. 215 | #latex_show_urls = False 216 | 217 | # Documents to append as an appendix to all manuals. 218 | #latex_appendices = [] 219 | 220 | # If false, no module index is generated. 221 | #latex_domain_indices = True 222 | 223 | 224 | # -- Options for manual page output --------------------------------------- 225 | 226 | # One entry per manual page. List of tuples 227 | # (source start file, name, description, authors, manual section). 228 | man_pages = [ 229 | ('index', 'eve-mongoengine', u'Eve-Mongoengine Documentation', 230 | [u'Stanislav Heller'], 1) 231 | ] 232 | 233 | # If true, show URL addresses after external links. 234 | #man_show_urls = False 235 | 236 | 237 | # -- Options for Texinfo output ------------------------------------------- 238 | 239 | # Grouping the document tree into Texinfo files. List of tuples 240 | # (source start file, target name, title, author, 241 | # dir menu entry, description, category) 242 | texinfo_documents = [ 243 | ('index', 'Eve-Mongoengine', u'Eve-Mongoengine Documentation', 244 | u'Stanislav Heller', 'Eve-Mongoengine', 'One line description of project.', 245 | 'Miscellaneous'), 246 | ] 247 | 248 | # Documents to append as an appendix to all manuals. 249 | #texinfo_appendices = [] 250 | 251 | # If false, no module index is generated. 252 | #texinfo_domain_indices = True 253 | 254 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 255 | #texinfo_show_urls = 'footnote' 256 | 257 | # If true, do not generate a @detailmenu in the "Top" node's menu. 258 | #texinfo_no_detailmenu = False 259 | -------------------------------------------------------------------------------- /docs/features.rst: -------------------------------------------------------------------------------- 1 | 2 | Features 3 | ======== 4 | 5 | Main features: 6 | 7 | * Auto-generated schema out of your mongoengine models 8 | * Every operation goes through mongoengine -> you do not loose your mongoengine hooks 9 | * Support for most of mongoengine fields (see `Limitations`_ for more info) 10 | * Mongoengine validation layer not disconnected - use it as you wish 11 | 12 | Validation 13 | ---------- 14 | 15 | By default, eve validates against cerberus schema. Because mongoengine has larger 16 | scale of validation possiblities, there are some cases, when cerberus is not enough. 17 | Eve-Mongoengine comes with fancy solution: all errors, which are catchable by cerberus, 18 | are catched by cerberus and mongoengine ones are catched by custom validator and 19 | returned in cerberus error format. Example of this case could be mongoengine's 20 | URLField, which does not have it's cerberus opposie. In this case, if you fill 21 | in wrong URL, you get mongoengine error message. Let's see an example with internet 22 | resource as a model:: 23 | 24 | class Resource(Document): 25 | url = URLField() 26 | author = StringField() 27 | 28 | And then if you make POST request with wrong URL:: 29 | 30 | $ curl -d '{"url": "not-an-url", "author": "John"}' -H 'Content-Type: application/json' http://my-eve-server/resource 31 | 32 | The response will contain:: 33 | 34 | {"_status": "ERR", "_issues": {'url': "ValidationError (Resource:None) (Invalid URL: not-an-url: ['url'])"}} 35 | 36 | 37 | Advanced model registration 38 | --------------------------- 39 | If you want to use the name of model class "as is", use option ``lowercase=False`` 40 | in ``add_model()`` method:: 41 | 42 | ext.add_model(Person, lowercase=False) 43 | 44 | Then you will have to ask the server for ``/Person/`` URL. 45 | 46 | In ``add_model()`` method you can add every possible parameter into resource settings. 47 | Even if you want to overwrite some settings, which generates eve-mongoengine under the hood, 48 | you can overwrite it this way:: 49 | 50 | ext.add_model(Person, # model or models 51 | resource_methods=['GET'], # allow only GET 52 | cache_control="max-age=600; must-revalidate") # set max-age 53 | 54 | When you register more than one model at time, you need to encapsulate all models into list:: 55 | 56 | ext.add_model([Person, Car, House, Dog]) 57 | 58 | **HTTP Methods** 59 | 60 | By default, all HTTP methods are allowed for registered classes: 61 | 62 | * resource methods: `GET, POST, DELETE` 63 | * item methods: `GET, PATCH, PUT, DELETE` 64 | 65 | 66 | About mongoengine fields 67 | ------------------------ 68 | Because Eve contains default functionality, which maintains fields 'updated' and 'created', 69 | there has to be special hacky way how to do it in mongoengine too. At the time of 70 | initializing EveMongoengine extension, all registered mongoengine classes get two 71 | new fields: ``updated`` and ``created``, both type mongoengine.DateTimeField (of 72 | course field names are taken from config values ``LAST_UPDATED`` and ``DATE_CREATED``. 73 | This is is the only way how to ensure, that Eve will have these fields avaliable for 74 | storing it's information about entity. So please, do not be surprised, that there 75 | are two more fields in your model class:: 76 | 77 | class Person(mongoengine.Document): 78 | name = mongoengine.StringField() 79 | age = mongoengine.IntField() 80 | 81 | app = Eve() 82 | ext = EveMongoengine(app) 83 | ext.add_model(Person) 84 | 85 | # Note that in db there are attributes '_updated' and '_created'. 86 | # Mongoengine field names are without underscore prefix! 87 | Person._fields.keys() # equals ['name', 'age', 'updated', 'created'] 88 | 89 | If you already have these fields in your model, Eve will probably scream at you, that 90 | it's not possible to have these fields in schema. 91 | 92 | 93 | User-defined fields 94 | ------------------- 95 | Sometimes you want to use your special kind of field, say ``HelloField``, which 96 | is build for your specific purpose. So how does eve-mongoengine know how to deal 97 | with it when you register a class with that field? Let have a look... 98 | 99 | Say ``HelloField`` is defined like this:: 100 | 101 | from mongoengine import StringField 102 | 103 | class HelloField(StringField): 104 | """ 105 | Allmighty string field, which counts number of 'Hello's in the 106 | string and throws exception if there are less than 2 'Hello's. 107 | """ 108 | 109 | Fancy, right? :) Well...not at all. But as an example it's fine. 110 | So what eve-mongoengine does with this? It checks the field and recognizes 111 | that it's non-standard field class. Then it looks into class bases and tries 112 | to get some known field class out of there. In this example the field will 113 | be considered as ``{'type': 'string'}``. Simple. 114 | 115 | Keep in mind that eve-mongoengine knows how to deal with only these fields, 116 | which are derived from non-BaseField classes, everything other will be 117 | considered as ``DynamicField``. 118 | 119 | 120 | Mongoengine hooks 121 | ----------------- 122 | 123 | If you use mongoengine hooks, you may be interested in what call is performed 124 | when POSTing documents or what kind of call is being executed while 125 | performing any other method from Eve's REST API. Here is the list you need: 126 | 127 | ============ ========================== 128 | HTTP method mongoengine's API call 129 | ============ ========================== 130 | GET resource :func:`QuerySet.filter()` + :func:`only(), exclude(), limit(), skip(), order_by()` 131 | GET item :func:`QuerySet.get()` (+ every filtering and 132 | limiting methods) 133 | POST item :func:`Document.save()` 134 | PUT item :func:`Document.save()` 135 | PATCH item :func:`QuerySet.update_one()` (atomic) 136 | DELETE item :func:`QuerySet.delete()` 137 | ============ ========================== 138 | 139 | So if you have some hook bound to ``save()`` method, it should be executed every 140 | POST and PUT call you make using Eve. But you have an option to use ``save()`` 141 | method in ``PATCH`` requests in exchange for one database fetch, so it is 142 | relatively slower. If you want to use this feature, set this options in data layer:: 143 | 144 | app = Eve() 145 | ext = EveMongoengine(app) 146 | #: this switches from using QuerySet.update_one() to Document.save() 147 | app.data.mongoengine_options['use_atomic_update_for_patch'] = False 148 | ext.add_model(Person) 149 | 150 | 151 | Limitations 152 | ----------- 153 | * You have to give Eve some dummy domain to shut him up. Without this he 154 | will complain about empty domain. 155 | * You cannot use mongoengine's custom ``primary_key`` (because of Eve). 156 | * Cannot use ``GenericEmbeddedDocumentField, SequenceField``. 157 | * Tested only on python 2.7 and 3.3. 158 | * If you update your document using mongoengine model (i.e. by calling ``save()``, 159 | the ``updated`` field wont be updated to current time. This is because there arent 160 | any hooks bound to ``save()`` or ``update()`` methods and I consider this evil. 161 | 162 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Eve-Mongoengine documentation master file, created by 2 | sphinx-quickstart on Fri Nov 1 09:05:09 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | :orphan: 7 | 8 | Welcome to Eve-Mongoengine 9 | ========================== 10 | 11 | Eve-Mongoengine is and Eve extension, which enables Mongoengine ODM models 12 | to be used as eve schema. If you use mongoengine in your application and 13 | simultaneously want to use eve, instead of writing schema again in cerberus 14 | format, you can use this extension, which takes your mongoengine models and 15 | auto-transforms it into creberus schema. 16 | 17 | Contents: 18 | 19 | .. toctree:: 20 | :maxdepth: 1 21 | 22 | features 23 | api 24 | 25 | 26 | Install 27 | ------- 28 | 29 | Simple installation using pip:: 30 | 31 | pip install eve-mongoengine 32 | 33 | It loads all dependencies as well (Eve and nothing more!). 34 | 35 | For development use virtualenv and editable copy of repisotory:: 36 | 37 | pip install -e git+https://github.com/hellerstanislav/eve-mongoengine#egg=eve-mongoengine 38 | 39 | 40 | Usage 41 | ----- 42 | :: 43 | 44 | import mongoengine 45 | from eve import Eve 46 | from eve_mongoengine import EveMongoengine 47 | 48 | # create some dummy model class 49 | class Person(mongoengine.Document): 50 | name = mongoengine.StringField() 51 | age = mongoengine.IntField() 52 | 53 | # default eve settings 54 | my_settings = { 55 | 'MONGO_HOST': 'localhost', 56 | 'MONGO_PORT': 27017, 57 | 'MONGO_DBNAME': 'eve_mongoengine_test' 58 | } 59 | 60 | # init application 61 | app = Eve(settings=my_settings) 62 | # init extension 63 | ext = EveMongoengine(app) 64 | # register model to eve 65 | ext.add_model(Person) 66 | 67 | # let's roll 68 | app.run() 69 | 70 | Or, if you are setting up your data before Eve is initialized, as is the case with application factories: 71 | 72 | :: 73 | 74 | import mongoengine 75 | from eve import Eve 76 | from eve_mongoengine import EveMongoengine 77 | 78 | ext = EveMongoengine() 79 | ... 80 | # init application 81 | app = Eve(settings=my_settings) 82 | 83 | # init extension 84 | ext.init_app(app) 85 | ... 86 | 87 | -------------------------------------------------------------------------------- /eve_mongoengine/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | eve_mongoengine 4 | ~~~~~~~~~~~~~~~ 5 | 6 | This module implements Eve extension which enables Mongoengine models 7 | to be used as eve schema. If you use mongoengine in your application 8 | and simultaneously want to use eve, instead of writing schema again in 9 | cerberus format, you can use this extension, which takes your mongoengine 10 | models and auto-transforms it into creberus schema. 11 | 12 | :copyright: (c) 2014 by Stanislav Heller. 13 | :license: BSD, see LICENSE for more details. 14 | """ 15 | 16 | from datetime import datetime 17 | 18 | import mongoengine 19 | 20 | from .schema import SchemaMapper 21 | from .datalayer import MongoengineDataLayer 22 | from .struct import Settings 23 | from .validation import EveMongoengineValidator 24 | from ._compat import itervalues, iteritems 25 | 26 | 27 | from .__version__ import get_version 28 | __version__ = get_version() 29 | 30 | 31 | def get_utc_time(): 32 | """ 33 | Returns current datetime in system-wide UTC format wichout microsecond 34 | part. 35 | """ 36 | return datetime.utcnow().replace(microsecond=0) 37 | 38 | 39 | class EveMongoengine(object): 40 | """ 41 | An extension to Eve which allows Mongoengine models to be registered 42 | as an Eve's "domain". 43 | 44 | Acts as Flask extension and implements its 'protocol'. 45 | 46 | Usage:: 47 | 48 | from eve_mongoengine import EveMongoengine 49 | from eve import Eve 50 | 51 | app = Eve() 52 | ext = EveMongoengine(app) 53 | ext.add_model([MyModel, MySuperModel]) 54 | 55 | This class tries hard to be extendable and hackable as possible, every 56 | possible value is either a method param (for IoC-DI) or class attribute, 57 | which can be overwriten in subclass. 58 | """ 59 | #: Default HTTP methods allowed to manipulate with whole resources. 60 | #: These are assigned to settings of every registered model, if not given 61 | #: others. 62 | default_resource_methods = ['GET', 'POST', 'DELETE'] 63 | 64 | #: Default HTTP methods allowed to manipulate with items (single records). 65 | #: These are assigned to settings of every registered model, if not given 66 | #: others. 67 | default_item_methods = ['GET', 'PATCH', 'PUT', 'DELETE'] 68 | 69 | #: The class used as Eve validator, which is also one of Eve's constructor 70 | #: params. In EveMongoengine, we need to overwrite it. If extending, assign 71 | #: only subclasses of :class:`EveMongoengineValidator`. 72 | validator_class = EveMongoengineValidator 73 | 74 | #: Datalayer class - instance of this class is pushed to app.data attribute 75 | #: and Eve does it's magic. See :class:`datalayer.MongoengineDataLayer` for 76 | #: more info. 77 | datalayer_class = MongoengineDataLayer 78 | 79 | #: The class used as settings dictionary. Usually subclass of dict with 80 | #: tuned methods/behaviour. 81 | settings_class = Settings 82 | 83 | #: Mapper from mongoengine model into cerberus schema. This class may be 84 | #: subclassed in the future to support new mongoenigne's fields. 85 | schema_mapper_class = SchemaMapper 86 | 87 | def __init__(self, app=None): 88 | self.models = {} 89 | if app is not None: 90 | self.init_app(app) 91 | 92 | def _parse_config(self): 93 | # parse app config 94 | config = self.app.config 95 | try: 96 | self.last_updated = config['LAST_UPDATED'] 97 | except KeyError: 98 | self.last_updated = '_updated' 99 | try: 100 | self.date_created = config['DATE_CREATED'] 101 | except KeyError: 102 | self.date_created = '_created' 103 | 104 | def init_app(self, app): 105 | """ 106 | Binds EveMongoengine extension to created eve application. 107 | 108 | Under the hood it fixes all registered models and overwrites default 109 | eve's datalayer :class:`eve.io.mongo.Mongo` into 110 | :class:`eve_mongoengine.datalayer.MongoengineDataLayer`. 111 | 112 | This method implements flask extension interface: 113 | :param app: eve application object, instance of :class:`eve.Eve`. 114 | """ 115 | self.app = app 116 | # overwrite default eve.io.mongo.validation.Validator 117 | app.validator = self.validator_class 118 | self._parse_config() 119 | # overwrite default data layer to get proper mongoengine functionality 120 | app.data = self.datalayer_class(self) 121 | 122 | def _set_default_settings(self, settings): 123 | """ 124 | Initializes default settings options for registered model. 125 | """ 126 | if 'resource_methods' not in settings: 127 | # TODO: maybe get from self.app.supported_resource_methods 128 | settings['resource_methods'] = list(self.default_resource_methods) 129 | if 'item_methods' not in settings: 130 | # TODO: maybe get from self.app.supported_item_methods 131 | settings['item_methods'] = list(self.default_item_methods) 132 | 133 | def add_model(self, models, lowercase=True, **settings): 134 | """ 135 | Creates Eve settings for mongoengine model classes. 136 | 137 | Returns dict which has to be passed to the settings param in Eve's 138 | constructor. 139 | 140 | :param model: model or list of them (subclasses of 141 | :class:`mongoengine.Document`). 142 | :param lowercase: if true, all class names will be taken lowercase as 143 | resource names. Default True. 144 | :param settings: any other keyword argument will be treated as param 145 | to settings dictionary. 146 | """ 147 | self._set_default_settings(settings) 148 | if not isinstance(models, (list, tuple)): 149 | models = [models] 150 | for model_cls in models: 151 | if not issubclass(model_cls, mongoengine.Document): 152 | raise TypeError("Class '%s' is not a subclass of " 153 | "mongoengine.Document." % model_cls.__name__) 154 | 155 | resource_name = model_cls.__name__ 156 | if lowercase: 157 | resource_name = resource_name.lower() 158 | 159 | # add new fields to model class to get proper Eve functionality 160 | self.fix_model_class(model_cls) 161 | self.models[resource_name] = model_cls 162 | 163 | schema = self.schema_mapper_class.create_schema(model_cls, 164 | lowercase) 165 | # create resource settings 166 | resource_settings = Settings({'schema': schema}) 167 | resource_settings.update(settings) 168 | # register to the app 169 | self.app.register_resource(resource_name, resource_settings) 170 | # add sub-resource functionality for every ReferenceField 171 | subresources = self.schema_mapper_class.get_subresource_settings 172 | for registration in subresources(model_cls, resource_name, 173 | resource_settings, lowercase): 174 | self.app.register_resource(*registration) 175 | self.models[registration[0]] = model_cls 176 | 177 | def fix_model_class(self, model_cls): 178 | """ 179 | Internal method invoked during registering new model. 180 | 181 | Adds necessary fields (updated and created) into model class 182 | to ensure Eve's default functionality. 183 | 184 | This is a helper for correct manipulation with mongoengine documents 185 | within Eve. Eve needs 'updated' and 'created' fields for it's own 186 | purpose, but we cannot ensure that they are present in the model 187 | class. And even if they are, they may be of other field type or 188 | missbehave. 189 | 190 | :param model_cls: mongoengine's model class (instance of subclass of 191 | :class:`mongoengine.Document`) to be fixed up. 192 | """ 193 | date_field_cls = mongoengine.DateTimeField 194 | 195 | # field names have to be non-prefixed 196 | last_updated_field_name = self.last_updated.lstrip('_') 197 | date_created_field_name = self.date_created.lstrip('_') 198 | new_fields = { 199 | # TODO: updating last_updated field every time when saved 200 | last_updated_field_name: date_field_cls(db_field=self.last_updated, 201 | default=get_utc_time), 202 | date_created_field_name: date_field_cls(db_field=self.date_created, 203 | default=get_utc_time) 204 | } 205 | 206 | for attr_name, attr_value in iteritems(new_fields): 207 | # If the field does exist, we just check if it has right 208 | # type (mongoengine.DateTimeField) and pass 209 | if attr_name in model_cls._fields: 210 | attr_value = model_cls._fields[attr_name] 211 | if not isinstance(attr_value, mongoengine.DateTimeField): 212 | info = (attr_name, attr_value.__class__.__name__) 213 | raise TypeError("Field '%s' is needed by Eve, but has" 214 | " wrong type '%s'." % info) 215 | continue 216 | # The way how we introduce new fields into model class is copied 217 | # out of mongoengine.base.DocumentMetaclass 218 | attr_value.name = attr_name 219 | if not attr_value.db_field: 220 | attr_value.db_field = attr_name 221 | # TODO: reverse-delete rules 222 | attr_value.owner_document = model_cls 223 | 224 | # now add a flag that this is automagically added field - it is 225 | # very useful when registering class more than once - create_schema 226 | # has to know, if it is user-added or auto-added field. 227 | attr_value.eve_field = True 228 | 229 | # now simulate DocumentMetaclass: add class attributes 230 | setattr(model_cls, attr_name, attr_value) 231 | model_cls._fields[attr_name] = attr_value 232 | model_cls._db_field_map[attr_name] = attr_value.db_field 233 | model_cls._reverse_db_field_map[attr_value.db_field] = attr_name 234 | 235 | # this is just copied from mongoengine and frankly, i just dont 236 | # have a clue, what it does... 237 | iterfields = itervalues(model_cls._fields) 238 | created = [(v.creation_counter, v.name) for v in iterfields] 239 | model_cls._fields_ordered = tuple(i[1] for i in sorted(created)) 240 | 241 | 242 | def fix_last_updated(sender, document, **kwargs): 243 | """ 244 | Hook which updates LAST_UPDATED field before every Document.save() call. 245 | """ 246 | from eve.utils import config 247 | field_name = config.LAST_UPDATED.lstrip('_') 248 | if field_name in document: 249 | document[field_name] = get_utc_time() 250 | 251 | mongoengine.signals.pre_save.connect(fix_last_updated) 252 | -------------------------------------------------------------------------------- /eve_mongoengine/__version__.py: -------------------------------------------------------------------------------- 1 | # Project Version 2 | 3 | # This file must remain compatible with 4 | # both Python >= 2.6 and Python 3.3+ 5 | 6 | VERSION = (0, 1, 0) # 0.1.0 7 | 8 | def get_version(): 9 | if isinstance(VERSION[-1], int): 10 | return '.'.join(map(str, VERSION)) 11 | return '.'.join(map(str, VERSION[:-1])) + VERSION[-1] 12 | 13 | -------------------------------------------------------------------------------- /eve_mongoengine/_compat.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | eve_mongoengine._compat 4 | ~~~~~~~~~~~~~~~~~~~~~~~ 5 | 6 | Internal module for Python 2 backwards compatibility 7 | 8 | :copyright: (c) 2014 by Stanislav Heller. 9 | :license: BSD, see LICENSE for more details. 10 | """ 11 | 12 | # flake8: noqa 13 | 14 | import sys 15 | 16 | if sys.version_info[0] < 3: 17 | iteritems = lambda x: x.iteritems() 18 | iterkeys = lambda x: x.iterkeys() 19 | itervalues = lambda x: x.itervalues() 20 | u = lambda x: x.decode() 21 | b = lambda x: x 22 | next = lambda x: x.next() 23 | byte_to_chr = lambda x: x 24 | unichr = unichr 25 | xrange = xrange 26 | basestring = basestring 27 | unicode = unicode 28 | bytes = str 29 | long = long 30 | else: 31 | iteritems = lambda x: iter(x.items()) 32 | iterkeys = lambda x: iter(x.keys()) 33 | itervalues = lambda x: iter(x.values()) 34 | u = lambda x: x 35 | b = lambda x: x.encode('iso-8859-1') if not isinstance(x, bytes) else x 36 | next = next 37 | unichr = chr 38 | imap = map 39 | izip = zip 40 | xrange = range 41 | basestring = str 42 | unicode = str 43 | bytes = bytes 44 | long = int 45 | -------------------------------------------------------------------------------- /eve_mongoengine/datalayer.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | eve_mongoengine.datalayer 4 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 5 | 6 | This module implements eve's data layer which uses mongoengine models 7 | instead of direct pymongo access. 8 | 9 | :copyright: (c) 2014 by Stanislav Heller. 10 | :license: BSD, see LICENSE for more details. 11 | """ 12 | 13 | # builtin 14 | import sys 15 | import ast 16 | import json 17 | from uuid import UUID 18 | import traceback 19 | from distutils.version import LooseVersion 20 | 21 | # --- Third Party --- 22 | 23 | # MongoEngine 24 | from mongoengine import __version__ 25 | from mongoengine import (DoesNotExist, FileField) 26 | from mongoengine.connection import get_db, connect 27 | 28 | MONGOENGINE_VERSION = LooseVersion(__version__) 29 | 30 | # Eve 31 | from eve.io.mongo import Mongo, MongoJSONEncoder 32 | from eve.io.mongo.parser import parse, ParseError 33 | from eve.utils import ( 34 | config, debug_error_message, validate_filters, document_etag 35 | ) 36 | from eve.exceptions import ConfigException 37 | 38 | # Misc 39 | from werkzeug.exceptions import HTTPException 40 | from flask import abort 41 | import pymongo 42 | 43 | 44 | # Python3 compatibility 45 | from ._compat import iteritems 46 | 47 | 48 | def _itemize(maybe_dict): 49 | if isinstance(maybe_dict, list): 50 | return maybe_dict 51 | elif isinstance(maybe_dict, dict): 52 | return iteritems(maybe_dict) 53 | else: 54 | raise TypeError("Wrong type to itemize. Allowed lists and dicts.") 55 | 56 | 57 | def clean_doc(doc): 58 | """ 59 | Cleans empty datastructures from mongoengine document (model instance) 60 | and remove any _etag fields. 61 | 62 | The purpose of this is to get proper etag. 63 | """ 64 | for attr, value in iteritems(dict(doc)): 65 | if isinstance(value, (list, dict)) and not value: 66 | del doc[attr] 67 | doc.pop('_etag', None) 68 | 69 | return doc 70 | 71 | 72 | class PymongoQuerySet(object): 73 | """ 74 | Dummy mongoenigne-like QuerySet behaving just like queryset 75 | with as_pymongo() called, but returning ALL fields in subdocuments 76 | (which as_pymongo() somehow filters). 77 | """ 78 | def __init__(self, qs): 79 | self._qs = qs 80 | 81 | def __iter__(self): 82 | def iterate(obj): 83 | qs = object.__getattribute__(obj, '_qs') 84 | for doc in qs: 85 | doc = dict(doc.to_mongo()) 86 | for attr, value in iteritems(dict(doc)): 87 | if isinstance(value, (list, dict)) and not value: 88 | del doc[attr] 89 | yield doc 90 | return iterate(self) 91 | 92 | def __getattribute__(self, name): 93 | return getattr(object.__getattribute__(self, '_qs'), name) 94 | 95 | 96 | class MongoengineJsonEncoder(MongoJSONEncoder): 97 | """ 98 | Propretary JSON encoder to support special mongoengine's special fields. 99 | """ 100 | def default(self, obj): 101 | if isinstance(obj, UUID): 102 | # rendered as a string 103 | return str(obj) 104 | else: 105 | # delegate rendering to base class method 106 | return super(MongoengineJsonEncoder, self).default(obj) 107 | 108 | 109 | class ResourceClassMap(object): 110 | """ 111 | Helper class providing translation from resource names to mongoengine 112 | models and their querysets. 113 | """ 114 | def __init__(self, datalayer): 115 | self.datalayer = datalayer 116 | 117 | def __getitem__(self, resource): 118 | try: 119 | return self.datalayer.models[resource] 120 | except KeyError: 121 | abort(404) 122 | 123 | def objects(self, resource): 124 | """ 125 | Returns QuerySet instance of resource's class thourh mongoengine 126 | QuerySetManager. If there is some different queryset_manager 127 | defined in the MongoengineDataLayer class, it tries to use that one 128 | first. 129 | """ 130 | _cls = self[resource] 131 | try: 132 | return getattr(_cls, self.datalayer.default_queryset) 133 | except AttributeError: 134 | # falls back to default `objects` QuerySet 135 | return _cls.objects 136 | 137 | 138 | class MongoengineUpdater(object): 139 | """ 140 | Helper class for managing updates (PATCH requests) through mongoengine 141 | ODM layer. 142 | 143 | Updates are managed in this class cecause sometimes things need to get 144 | drity and there would be unnecessary 'helper' methods in the main class 145 | MongoengineDataLayer causing namespace pollution. 146 | """ 147 | def __init__(self, datalayer): 148 | self.datalayer = datalayer 149 | self._etag_doc = None 150 | self.install_etag_fixer() 151 | 152 | def install_etag_fixer(self): 153 | """ 154 | Fixes ETag value returned by PATCH responses. 155 | """ 156 | def fix_patch_etag(resource, request, payload): 157 | if self._etag_doc is None: 158 | return 159 | # make doc from which the etag will be computed 160 | etag_doc = clean_doc(self._etag_doc) 161 | # load the response back agagin from json 162 | d = json.loads(payload.get_data(as_text=True)) 163 | # compute new etag 164 | d[config.ETAG] = document_etag(etag_doc) 165 | payload.set_data(json.dumps(d)) 166 | # register post PATCH hook into current application 167 | self.datalayer.app.on_post_PATCH += fix_patch_etag 168 | 169 | def _transform_updates_to_mongoengine_kwargs(self, resource, updates): 170 | """ 171 | Transforms update dict to special mongoengine syntax with set__, 172 | unset__ etc. 173 | """ 174 | field_cls = self.datalayer.cls_map[resource] 175 | nopfx = lambda x: field_cls._reverse_db_field_map[x] 176 | return dict(("set__%s" % nopfx(k), v) for (k, v) in iteritems(updates)) 177 | 178 | def _has_empty_list_recurse(self, value): 179 | if value == []: 180 | return True 181 | if isinstance(value, dict): 182 | return self._has_empty_list(value) 183 | elif isinstance(value, list): 184 | for val in value: 185 | if self._has_empty_list_recurse(val): 186 | return True 187 | return False 188 | 189 | def _has_empty_list(self, updates): 190 | """ 191 | Traverses updates and returns True if there is update to empty list. 192 | """ 193 | for key, value in iteritems(updates): 194 | if self._has_empty_list_recurse(value): 195 | return True 196 | return False 197 | 198 | def _update_using_update_one(self, resource, id_, updates): 199 | """ 200 | Updates one document atomically using QuerySet.update_one(). 201 | """ 202 | kwargs = self._transform_updates_to_mongoengine_kwargs(resource, 203 | updates) 204 | qset = lambda: self.datalayer.cls_map.objects(resource) 205 | qry = qset()(id=id_) 206 | qry.update_one(write_concern=self.datalayer._wc(resource), **kwargs) 207 | if self._has_empty_list(updates): 208 | # Fix Etag when updating to empty list 209 | model = qset()(id=id_).get() 210 | self._etag_doc = dict(model.to_mongo()) 211 | else: 212 | self._etag_doc = None 213 | 214 | def _update_document(self, doc, updates): 215 | """ 216 | Makes appropriate calls to update mongoengine document properly by 217 | update definition given from REST API. 218 | """ 219 | for db_field, value in iteritems(updates): 220 | field_name = doc._reverse_db_field_map[db_field] 221 | field = doc._fields[field_name] 222 | doc[field_name] = field.to_python(value) 223 | return doc 224 | 225 | def _update_using_save(self, resource, id_, updates): 226 | """ 227 | Updates one document non-atomically using Document.save(). 228 | """ 229 | model = self.datalayer.cls_map.objects(resource)(id=id_).get() 230 | self._update_document(model, updates) 231 | model.save(write_concern=self.datalayer._wc(resource)) 232 | # Fix Etag when updating to empty list 233 | self._etag_doc = dict(model.to_mongo()) 234 | 235 | def update(self, resource, id_, updates): 236 | """ 237 | Resolves update for PATCH request. 238 | 239 | Does not handle mongo errros! 240 | """ 241 | opt = self.datalayer.mongoengine_options 242 | 243 | updates.pop('_etag', None) 244 | 245 | if opt.get('use_atomic_update_for_patch', 1): 246 | self._update_using_update_one(resource, id_, updates) 247 | else: 248 | self._update_using_save(resource, id_, updates) 249 | return self._etag_doc 250 | 251 | 252 | class MongoengineDataLayer(Mongo): 253 | """ 254 | Data layer for eve-mongoengine extension. 255 | 256 | Most of functionality is copied from :class:`eve.io.mongo.Mongo`. 257 | """ 258 | #: default JSON encoder 259 | json_encoder_class = MongoengineJsonEncoder 260 | 261 | #: name of default queryset, where datalayer asks for data 262 | default_queryset = 'objects' 263 | 264 | #: Options for usage of mongoengine layer. 265 | #: use_atomic_update_for_patch - when set to True, Mongoengine layer will 266 | #: use update_one() method (which is atomic) for updating. But then you 267 | #: will loose your pre/post-save hooks. When you set this to False, for 268 | #: updating will be used save() method. 269 | mongoengine_options = { 270 | 'use_atomic_update_for_patch': True 271 | } 272 | 273 | def __init__(self, ext): 274 | """ 275 | Constructor. 276 | 277 | :param ext: instance of :class:`EveMongoengine`. 278 | """ 279 | # get authentication info 280 | username = ext.app.config.get('MONGO_USERNAME', None) 281 | password = ext.app.config.get('MONGO_PASSWORD', None) 282 | auth = (username, password) 283 | if any(auth) and not all(auth): 284 | raise ConfigException('Must set both USERNAME and PASSWORD ' 285 | 'or neither') 286 | # try to connect to db 287 | self.conn = connect(ext.app.config['MONGO_DBNAME'], 288 | host=ext.app.config['MONGO_HOST'], 289 | port=ext.app.config['MONGO_PORT']) 290 | self.models = ext.models 291 | self.app = ext.app 292 | # create dummy driver instead of PyMongo, which causes errors 293 | # when instantiating after config was initialized 294 | self.driver = type('Driver', (), {})() 295 | self.driver.db = get_db() 296 | # authenticate 297 | if any(auth): 298 | self.driver.db.authenticate(username, password) 299 | # helper object for managing PATCHes, which are a bit dirty 300 | self.updater = MongoengineUpdater(self) 301 | # map resource -> Mongoengine class 302 | self.cls_map = ResourceClassMap(self) 303 | 304 | def _handle_exception(self, exc): 305 | """ 306 | If application is in debug mode, prints every traceback to stderr. 307 | """ 308 | if self.app.debug: 309 | traceback.print_exc(file=sys.stderr) 310 | raise exc 311 | 312 | def _projection(self, resource, projection, qry): 313 | """ 314 | Ensures correct projection for mongoengine query. 315 | """ 316 | if projection is None: 317 | return qry 318 | 319 | model_cls = self.cls_map[resource] 320 | 321 | projection_value = set(projection.values()) 322 | projection = set(projection.keys()) 323 | 324 | # strip special underscore prefixed attributes -> in mongoengine 325 | # they arent prefixed 326 | projection.discard('_id') 327 | 328 | # We must translate any database field names to their corresponding 329 | # MongoEngine names before attempting to use them. 330 | translate = lambda x: model_cls._reverse_db_field_map.get(x) 331 | projection = [translate(field) for field in projection if 332 | field in model_cls._reverse_db_field_map] 333 | 334 | if 0 in projection_value: 335 | qry = qry.exclude(*projection) 336 | else: 337 | # id has to be always there 338 | projection.append('id') 339 | qry = qry.only(*projection) 340 | return qry 341 | 342 | def find(self, resource, req, sub_resource_lookup): 343 | """ 344 | Seach for results and return list of them. 345 | 346 | :param resource: name of requested resource as string. 347 | :param req: instance of :class:`eve.utils.ParsedRequest`. 348 | :param sub_resource_lookup: sub-resource lookup from the endpoint url. 349 | """ 350 | qry = self.cls_map.objects(resource) 351 | 352 | client_projection = {} 353 | client_sort = {} 354 | spec = {} 355 | 356 | # TODO sort syntax should probably be coherent with 'where': either 357 | # mongo-like # or python-like. Currently accepts only mongo-like sort 358 | # syntax. 359 | 360 | # TODO should validate on unknown sort fields (mongo driver doesn't 361 | # return an error) 362 | if req.sort: 363 | try: 364 | client_sort = ast.literal_eval(req.sort) 365 | except Exception as e: 366 | abort(400, description=debug_error_message(str(e))) 367 | 368 | if req.where: 369 | try: 370 | spec = self._sanitize(json.loads(req.where)) 371 | except HTTPException as e: 372 | # _sanitize() is raising an HTTP exception; let it fire. 373 | raise 374 | except: 375 | try: 376 | spec = parse(req.where) 377 | except ParseError: 378 | abort(400, description=debug_error_message( 379 | 'Unable to parse `where` clause' 380 | )) 381 | 382 | if sub_resource_lookup: 383 | spec.update(sub_resource_lookup) 384 | 385 | spec = self._mongotize(spec, resource) 386 | 387 | bad_filter = validate_filters(spec, resource) 388 | if bad_filter: 389 | abort(400, bad_filter) 390 | 391 | client_projection = self._client_projection(req) 392 | 393 | datasource, spec, projection, sort = self._datasource_ex( 394 | resource, 395 | spec, 396 | client_projection, 397 | client_sort) 398 | # apply ordering 399 | if sort: 400 | for field, direction in _itemize(sort): 401 | if direction < 0: 402 | field = "-%s" % field 403 | qry = qry.order_by(field) 404 | # apply filters 405 | if req.if_modified_since: 406 | spec[config.LAST_UPDATED] = \ 407 | {'$gt': req.if_modified_since} 408 | if len(spec) > 0: 409 | qry = qry.filter(__raw__=spec) 410 | # apply projection 411 | qry = self._projection(resource, projection, qry) 412 | # apply limits 413 | if req.max_results: 414 | qry = qry.limit(int(req.max_results)) 415 | if req.page > 1: 416 | qry = qry.skip((req.page - 1) * req.max_results) 417 | return PymongoQuerySet(qry) 418 | 419 | def find_one(self, resource, req, **lookup): 420 | """ 421 | Look for one object. 422 | """ 423 | # transform every field value to correct type for querying 424 | lookup = self._mongotize(lookup, resource) 425 | 426 | client_projection = self._client_projection(req) 427 | datasource, filter_, projection, _ = self._datasource_ex( 428 | resource, 429 | lookup, 430 | client_projection) 431 | qry = self.cls_map.objects(resource) 432 | 433 | if len(filter_) > 0: 434 | qry = qry.filter(__raw__=filter_) 435 | 436 | qry = self._projection(resource, projection, qry) 437 | try: 438 | doc = dict(qry.get().to_mongo()) 439 | return clean_doc(doc) 440 | except DoesNotExist: 441 | return None 442 | 443 | def _doc_to_model(self, resource, doc): 444 | 445 | # Strip underscores from special key names 446 | if '_id' in doc: 447 | doc['id'] = doc.pop('_id') 448 | 449 | cls = self.cls_map[resource] 450 | 451 | # We must translate any database field names to their corresponding 452 | # MongoEngine names before attempting to use them. 453 | translate = lambda x: cls._reverse_db_field_map.get(x, x) 454 | doc = {translate(k): doc[k] for k in doc} 455 | 456 | # MongoEngine 0.9 now throws an FieldDoesNotExist when initializing a 457 | # Document with unknown keys. 458 | if MONGOENGINE_VERSION >= LooseVersion("0.9.0"): 459 | from mongoengine import FieldDoesNotExist 460 | doc_keys = set(cls._fields) & set(doc) 461 | try: 462 | instance = cls(**{k: doc[k] for k in doc_keys}) 463 | except FieldDoesNotExist as e: 464 | abort(422, description=debug_error_message( 465 | 'mongoengine.FieldDoesNotExist: %s' % e 466 | )) 467 | else: 468 | instance = cls(**doc) 469 | 470 | for attr, field in iteritems(cls._fields): 471 | # Inject GridFSProxy object into the instance for every FileField. 472 | # This is because the Eve's GridFS layer does not work with the 473 | # model object, but handles insertion in his own workspace. Sadly, 474 | # there's no way how to work around this, so we need to do this 475 | # special hack.. 476 | if isinstance(field, FileField): 477 | if attr in doc: 478 | proxy = field.get_proxy_obj(key=field.name, 479 | instance=instance) 480 | proxy.grid_id = doc[attr] 481 | instance._data[attr] = proxy 482 | return instance 483 | 484 | def insert(self, resource, doc_or_docs): 485 | """Called when performing POST request""" 486 | datasource, filter_, _, _ = self._datasource_ex(resource) 487 | try: 488 | if not isinstance(doc_or_docs, list): 489 | doc_or_docs = [doc_or_docs] 490 | 491 | ids = [] 492 | for doc in doc_or_docs: 493 | model = self._doc_to_model(resource, doc) 494 | model.save(write_concern=self._wc(resource)) 495 | ids.append(model.id) 496 | doc.update(dict(model.to_mongo())) 497 | doc[config.ID_FIELD] = model.id 498 | # Recompute ETag since MongoEngine can modify the data via 499 | # save hooks. 500 | clean_doc(doc) 501 | doc['_etag'] = document_etag(doc) 502 | return ids 503 | except pymongo.errors.OperationFailure as e: 504 | # most likely a 'w' (write_concern) setting which needs an 505 | # existing ReplicaSet which doesn't exist. Please note that the 506 | # update will actually succeed (a new ETag will be needed). 507 | abort(500, description=debug_error_message( 508 | 'pymongo.errors.OperationFailure: %s' % e 509 | )) 510 | except Exception as exc: 511 | self._handle_exception(exc) 512 | 513 | def update(self, resource, id_, updates, *args, **kwargs): 514 | """Called when performing PATCH request.""" 515 | try: 516 | return self.updater.update(resource, id_, updates) 517 | except pymongo.errors.OperationFailure as e: 518 | # see comment in :func:`insert()`. 519 | abort(500, description=debug_error_message( 520 | 'pymongo.errors.OperationFailure: %s' % e 521 | )) 522 | except Exception as exc: 523 | self._handle_exception(exc) 524 | 525 | def replace(self, resource, id_, document, *args, **kwargs): 526 | """Called when performing PUT request.""" 527 | try: 528 | # FIXME: filters? 529 | model = self._doc_to_model(resource, document) 530 | model.save(write_concern=self._wc(resource)) 531 | except pymongo.errors.OperationFailure as e: 532 | # see comment in :func:`insert()`. 533 | abort(500, description=debug_error_message( 534 | 'pymongo.errors.OperationFailure: %s' % e 535 | )) 536 | except Exception as exc: 537 | self._handle_exception(exc) 538 | 539 | def remove(self, resource, lookup): 540 | """Called when performing DELETE request.""" 541 | lookup = self._mongotize(lookup, resource) 542 | datasource, filter_, _, _ = self._datasource_ex(resource, lookup) 543 | 544 | try: 545 | if not filter_: 546 | qry = self.cls_map.objects(resource) 547 | else: 548 | qry = self.cls_map.objects(resource)(__raw__=filter_) 549 | qry.delete(write_concern=self._wc(resource)) 550 | except pymongo.errors.OperationFailure as e: 551 | # see comment in :func:`insert()`. 552 | abort(500, description=debug_error_message( 553 | 'pymongo.errors.OperationFailure: %s' % e 554 | )) 555 | except Exception as exc: 556 | self._handle_exception(exc) 557 | -------------------------------------------------------------------------------- /eve_mongoengine/schema.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | eve_mongoengine.schema 4 | ~~~~~~~~~~~~~~~~~~~~~~ 5 | 6 | Mapping mongoengine field types to cerberus schema. 7 | 8 | :copyright: (c) 2014 by Stanislav Heller. 9 | :license: BSD, see LICENSE for more details. 10 | """ 11 | 12 | import copy 13 | 14 | # MongoEngine Fields 15 | from mongoengine import (StringField, IntField, FloatField, BooleanField, 16 | DateTimeField, ComplexDateTimeField, URLField, 17 | EmailField, LongField, DecimalField, ListField, 18 | EmbeddedDocumentField, SortedListField, DictField, 19 | MapField, UUIDField, ObjectIdField, LineStringField, 20 | GeoPointField, PointField, PolygonField, BinaryField, 21 | ReferenceField, DynamicField, FileField) 22 | from mongoengine import DynamicDocument 23 | 24 | from eve.exceptions import SchemaException 25 | 26 | 27 | class SchemaMapper(object): 28 | """ 29 | Default mapper from mongoengine model classes into cerberus dict-like 30 | schema. 31 | """ 32 | _mongoengine_to_cerberus = { 33 | StringField: 'string', 34 | IntField: 'integer', 35 | FloatField: 'float', 36 | BooleanField: 'boolean', 37 | DateTimeField: 'datetime', 38 | ComplexDateTimeField: 'datetime', 39 | URLField: 'string', 40 | EmailField: 'string', 41 | LongField: 'integer', 42 | DecimalField: 'float', 43 | EmbeddedDocumentField: 'dict', 44 | ListField: 'list', 45 | SortedListField: 'list', 46 | DictField: 'dict', 47 | MapField: 'dict', 48 | UUIDField: 'string', 49 | ObjectIdField: 'objectid', 50 | LineStringField: 'dict', 51 | GeoPointField: 'list', 52 | PointField: 'dict', 53 | PolygonField: 'dict', 54 | BinaryField: 'string', 55 | ReferenceField: 'objectid', 56 | FileField: 'media' 57 | 58 | # NOT SUPPORTED: 59 | # ImageField, SequenceField 60 | # GenericEmbeddedDocumentField 61 | } 62 | 63 | @classmethod 64 | def _resolve_field_class(cls, field): 65 | """ 66 | Resolves field classes, which are non-standard (derived from existing 67 | ones) to get most of it's functionality. 68 | If no appropriate class is found for this field, returns DynamicField. 69 | """ 70 | for klass in field.__class__.mro(): 71 | if klass in cls._mongoengine_to_cerberus: 72 | return klass 73 | return DynamicField 74 | 75 | @classmethod 76 | def create_schema(cls, model_cls, lowercase=True): 77 | """ 78 | :param model_cls: Mongoengine model class, subclass of 79 | :class:`mongoengine.Document`. 80 | :param lowercase: True if names of resource for model class has to be 81 | treated as lowercase string of classname. 82 | """ 83 | schema = {} 84 | 85 | # A DynamicDocument in MongoEngine is an expandable / uncontrolled 86 | # schema type. Any data set against the DynamicDocument that is not a 87 | # pre-defined field is automatically converted to a DynamicField. 88 | if issubclass(model_cls, DynamicDocument): 89 | schema['allow_unknown'] = True 90 | 91 | for field in model_cls._fields.values(): 92 | if field.primary_key: 93 | # defined custom primary key -> fail, cos eve doesnt support it 94 | raise SchemaException("Custom primery key not allowed - eve " 95 | "does not support different id fields " 96 | "for resources.") 97 | fname = field.db_field 98 | if getattr(field, 'eve_field', False): 99 | # Do not convert auto-added fields 'updated' and 'created'. 100 | # This attribute is injected into model in EveMongoengine's 101 | # fix_model_class() method. 102 | continue 103 | if fname in ('_id', 'id'): 104 | # default id field, do not insert it into schema 105 | continue 106 | 107 | schema[fname] = cls.process_field(field, lowercase) 108 | return schema 109 | 110 | @classmethod 111 | def process_field(cls, field, lowercase): 112 | """ 113 | Returns Eve field definition from Mongoengine field 114 | 115 | :param field: Mongoengine field 116 | :param lowercase: True if names of resource for model class has to be 117 | treated as lowercase string of classname. 118 | """ 119 | fdict = {} 120 | best_matching_cls = cls._resolve_field_class(field) 121 | 122 | if best_matching_cls in cls._mongoengine_to_cerberus: 123 | cerberus_type = cls._mongoengine_to_cerberus[best_matching_cls] 124 | fdict['type'] = cerberus_type 125 | 126 | # Allow null, which causes field to be deleted from db. 127 | # This cannot be fetched from field.null, because it would 128 | # cause allowance of nulls in db. We only want nulls in REST API. 129 | fdict['nullable'] = True 130 | 131 | if isinstance(field, EmbeddedDocumentField): 132 | fdict['schema'] = cls.create_schema(field.document_type) 133 | if isinstance(field, ListField): 134 | fdict['schema'] = cls.process_field(field.field, lowercase) 135 | 136 | if field.required: 137 | fdict['required'] = True 138 | if field.unique: 139 | fdict['unique'] = True 140 | if field.choices: 141 | allowed = [] 142 | for choice in field.choices: 143 | if isinstance(choice, (list, tuple)): 144 | allowed.append(choice[0]) 145 | else: 146 | allowed.append(choice) 147 | fdict['allowed'] = tuple(allowed) 148 | if getattr(field, 'max_length', None) is not None: 149 | fdict['maxlength'] = field.max_length 150 | if getattr(field, 'min_length', None) is not None: 151 | fdict['minlength'] = field.min_length 152 | if getattr(field, 'max_value', None) is not None: 153 | fdict['max'] = field.max_value 154 | if getattr(field, 'min_value', None) is not None: 155 | fdict['min'] = field.min_value 156 | 157 | # special cases 158 | if best_matching_cls is ReferenceField: 159 | # create data_relation schema 160 | resource = field.document_type.__name__ 161 | if lowercase: 162 | resource = resource.lower() 163 | fdict['data_relation'] = { 164 | 'resource': resource, 165 | 'field': '_id', 166 | 'embeddable': True 167 | } 168 | 169 | elif best_matching_cls is DynamicField: 170 | fdict['type'] = 'dynamic' 171 | 172 | return fdict 173 | 174 | @classmethod 175 | def get_subresource_settings(cls, model_cls, resource_name, 176 | resource_settings, lowercase=True): 177 | """ 178 | Yields name of subresource domain and it's settings. 179 | """ 180 | for field in model_cls._fields.values(): 181 | if field.__class__ is ReferenceField: 182 | fname = field.db_field 183 | subresource_settings = copy.deepcopy(resource_settings) 184 | subresource = field.document_type.__name__ 185 | if lowercase: 186 | subresource = subresource.lower() 187 | # FIXME what if id is of other type? 188 | _url = '%s//%s' 189 | subresource_settings['url'] = _url % (subresource, fname, 190 | resource_name) 191 | yield subresource+resource_name, subresource_settings 192 | -------------------------------------------------------------------------------- /eve_mongoengine/struct.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | eve_mongoengine.struct 4 | ~~~~~~~~~~~~~~~~~~~~~~ 5 | 6 | Datastructures for eve-mongoengine. 7 | 8 | :copyright: (c) 2014 by Stanislav Heller. 9 | :license: BSD, see LICENSE for more details. 10 | """ 11 | 12 | 13 | def _merge_dicts(d1, d2): 14 | """ 15 | Helper function for merging dicts. This functino is called in 16 | :func:`Settings.update`. 17 | """ 18 | for key, value in d2.items(): 19 | if key in d1: 20 | if isinstance(d1[key], dict) and isinstance(value, dict): 21 | _merge_dicts(d1[key], value) 22 | continue 23 | d1[key] = value 24 | 25 | 26 | class Settings(dict): 27 | """ 28 | Special mergable dictionary for eve settings. Used as config keeper 29 | returned by method :func:`EveMongoengine.create_settings`. 30 | 31 | The difference between Settings object and default dict is that update() 32 | method in Settings does not overwrite the key when value is dictionary, 33 | but tries to merge inner dicts in an intelligent way. 34 | """ 35 | def update(self, other): 36 | """Update method, which respects dictionaries recursively.""" 37 | _merge_dicts(self, other) 38 | 39 | __all__ = [Settings] 40 | -------------------------------------------------------------------------------- /eve_mongoengine/validation.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | eve_mongoengine.validation 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | 7 | This module implements custom validator based on 8 | :class:`eve.io.mongo.validation`, which is cerberus-validator extension. 9 | 10 | The purpose of this module is to enable validation for special mongoengine 11 | fields. 12 | 13 | :copyright: (c) 2014 by Stanislav Heller. 14 | :license: BSD, see LICENSE for more details. 15 | """ 16 | 17 | from flask import current_app as app 18 | from mongoengine import ValidationError, FileField 19 | 20 | from eve.io.mongo.validation import Validator 21 | from eve_mongoengine._compat import iteritems 22 | 23 | 24 | class EveMongoengineValidator(Validator): 25 | """ 26 | Helper validator which adapts mongoengine special-purpose fields 27 | to cerberus validator API. 28 | """ 29 | def validate(self, document, schema=None, update=False, context=None): 30 | """ 31 | Main validation method which simply tries to validate against cerberus 32 | schema and if it does not fail, repeats the same against mongoengine 33 | validation machinery. 34 | """ 35 | # call default eve's validator 36 | if not Validator.validate(self, document, schema, update, context): 37 | return False 38 | 39 | # validate using mongoengine field validators 40 | if self.resource and context is None: 41 | model_cls = app.data.models[self.resource] 42 | 43 | # We must translate any database field names to their corresponding 44 | # MongoEngine names before attempting to validate them. 45 | translate = lambda x: model_cls._reverse_db_field_map.get(x, x) 46 | document = {translate(k): document[k] for k in document} 47 | 48 | doc = model_cls(**document) 49 | # rewind all file-like's 50 | for attr, field in iteritems(model_cls._fields): 51 | if isinstance(field, FileField) and attr in document: 52 | document[attr].stream.seek(0) 53 | try: 54 | doc.validate() 55 | except ValidationError as e: 56 | for field_name, error in e.errors.items(): 57 | self._error(field_name, str(e)) 58 | return False 59 | 60 | return True 61 | 62 | def _validate_type_dynamic(self, field, value): 63 | """ 64 | Dummy validation method just to convince cerberus not to validate that 65 | value. 66 | """ 67 | pass 68 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | eve>=0.5.3 2 | blinker 3 | mongoengine>=0.8.7,<=0.9 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | 5 | LONG_DESCRIPTION = None 6 | try: 7 | LONG_DESCRIPTION = open('README.rst').read() 8 | except: 9 | pass 10 | 11 | # Version Information 12 | # (Using 'execfile' is not version safe) 13 | exec(open('eve_mongoengine/__version__.py').read()) 14 | VERSION = get_version() 15 | 16 | extra_opts = dict() 17 | 18 | # Project Setup 19 | setup( 20 | name='eve-mongoengine', 21 | version=VERSION, 22 | url='https://github.com/seglberg/eve-mongoengine', 23 | author='Stanislav Heller', 24 | author_email='heller.stanislav@{nospam}gmail.com', 25 | maintainer="Matthew Ellison", 26 | maintainer_email="seglberg@gmail.com", 27 | description='An Eve extension for Mongoengine ODM support', 28 | long_description=LONG_DESCRIPTION, 29 | platforms=['any'], 30 | packages=find_packages(exclude=["test*"]), 31 | test_suite="tests", 32 | license='MIT', 33 | include_package_data=True, 34 | install_requires=[ 35 | 'Eve>=0.5.3', 36 | 'Blinker', 37 | 'Mongoengine>=0.8.7,<=0.9', 38 | ], 39 | **extra_opts 40 | ) 41 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | import json 3 | from flask import Response as BaseResponse 4 | from mongoengine import * 5 | import mongoengine.signals 6 | from eve import Eve 7 | 8 | from eve_mongoengine import EveMongoengine 9 | 10 | SETTINGS = { 11 | 'MONGO_HOST': 'localhost', 12 | 'MONGO_PORT': 27017, 13 | 'MONGO_DBNAME': 'eve_mongoengine_test', 14 | 'DOMAIN': {'eve-mongoengine': {}}, 15 | } 16 | 17 | class Response(BaseResponse): 18 | def get_json(self): 19 | if 'application/json' in self.mimetype: 20 | data = self.get_data() 21 | try: 22 | data = data.decode('utf-8') 23 | except UnicodeDecodeError: 24 | pass 25 | json_data = json.loads(data) 26 | 27 | # Convert the special keys back to _prefix 28 | if 'id' in json_data: 29 | json_data['_id'] = json_data.pop('id') 30 | if 'created' in json_data: 31 | json_data['_created'] = json_data.pop('created') 32 | if 'updated' in json_data: 33 | json_data['_updated'] = json_data.pop('updated') 34 | 35 | return json_data 36 | 37 | else: 38 | raise TypeError("Not an application/json response") 39 | 40 | # inject new reponse class for testing 41 | Eve.response_class = Response 42 | 43 | class SimpleDoc(Document): 44 | meta = { 45 | 'allow_inheritance': True 46 | } 47 | a = StringField() 48 | b = IntField() 49 | 50 | class Inherited(SimpleDoc): 51 | c = StringField(db_field='C') 52 | d = DictField() 53 | 54 | class Inner(EmbeddedDocument): 55 | a = StringField() 56 | b = IntField() 57 | 58 | class ListInner(EmbeddedDocument): 59 | ll = ListField(StringField()) 60 | 61 | class ComplexDoc(Document): 62 | # more complex field with embedded documents and lists 63 | i = EmbeddedDocumentField(Inner) 64 | d = DictField() 65 | l = ListField(StringField()) 66 | n = DynamicField() 67 | r = ReferenceField(SimpleDoc) 68 | o = ListField(EmbeddedDocumentField(Inner)) 69 | p = ListField(EmbeddedDocumentField(ListInner)) 70 | 71 | class LimitedDoc(Document): 72 | # doc for testing field limits and properties 73 | a = StringField(required=True) 74 | b = StringField(unique=True) 75 | c = StringField(choices=['x', 'y', 'z']) 76 | d = StringField(max_length=10) 77 | e = StringField(min_length=10) 78 | f = IntField(min_value=5, max_value=10) 79 | g = StringField(choices=[ 80 | ['val1', 'test value 1'], 81 | ('val2', 'test value 2'), 82 | ['val3', 'test value 3']]) 83 | 84 | class WrongDoc(Document): 85 | updated = IntField() # this is bad name 86 | 87 | class FancyStringField(StringField): 88 | pass 89 | 90 | class FieldsDoc(Document): 91 | # special document for testing any other field types 92 | a = URLField() 93 | b = EmailField() 94 | c = LongField() 95 | d = DecimalField() 96 | e = SortedListField(IntField()) 97 | f = MapField(StringField()) 98 | g = UUIDField() 99 | h = ObjectIdField() 100 | i = BinaryField() 101 | 102 | j = LineStringField() 103 | k = GeoPointField() 104 | l = PointField() 105 | m = PolygonField() 106 | n = StringField(db_field='longFieldName') 107 | o = FancyStringField() 108 | p = FileField() 109 | 110 | class PrimaryKeyDoc(Document): 111 | # special document for testing primary key 112 | abc = StringField(db_field='ABC', primary_key=True) 113 | x = IntField() 114 | 115 | class NonStructuredDoc(Document): 116 | # special document with custom db_field but without 117 | # any structured field (listField, dictField etc.) 118 | new_york = StringField(db_field='NewYork') 119 | 120 | class HawkeyDoc(Document): 121 | # document with save() hooked 122 | a = StringField() 123 | b = StringField() 124 | 125 | def update_b(sender, document): 126 | document.b = document.a * 2 # 'a' -> 'aa' 127 | mongoengine.signals.pre_save.connect(update_b, sender=HawkeyDoc) 128 | 129 | 130 | class BaseTest(object): 131 | @classmethod 132 | def setUpClass(cls): 133 | SETTINGS['DOMAIN'] = {'eve-mongoengine':{}} 134 | app = Eve(settings=SETTINGS) 135 | app.debug = True 136 | ext = EveMongoengine(app) 137 | ext.add_model([SimpleDoc, ComplexDoc, LimitedDoc, FieldsDoc, 138 | NonStructuredDoc, Inherited, HawkeyDoc]) 139 | cls.ext = ext 140 | cls.client = app.test_client() 141 | cls.app = app 142 | 143 | @classmethod 144 | def tearDownClass(cls): 145 | # deletes the whole test database 146 | cls.app.data.conn.drop_database(SETTINGS['MONGO_DBNAME']) 147 | 148 | -------------------------------------------------------------------------------- /tests/test_delete.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from eve.utils import config 4 | 5 | from tests import BaseTest, SimpleDoc, ComplexDoc 6 | 7 | 8 | class TestHttpDelete(BaseTest, unittest.TestCase): 9 | def setUp(self): 10 | response = self.client.post('/simpledoc/', 11 | data='[{"a": "jimmy", "b": 23}, {"a": "steve", "b": 77}]', 12 | content_type='application/json') 13 | json_data = response.get_json() 14 | ids = tuple(x['_id'] for x in json_data[config.ITEMS]) 15 | url = '/simpledoc?where={"$or": [{"_id": "%s"}, {"_id": "%s"}]}' % ids 16 | response = self.client.get(url).get_json() 17 | item = response[config.ITEMS][0] 18 | self.etag = item[config.ETAG] 19 | self._id = item[config.ID_FIELD] 20 | self._id2 = response[config.ITEMS][1][config.ID_FIELD] 21 | 22 | def tearDown(self): 23 | SimpleDoc.objects().delete() 24 | 25 | def delete(self, url): 26 | return self.client.delete(url, headers=[('If-Match', self.etag)]) 27 | 28 | def test_delete_item(self): 29 | url = '/simpledoc/%s' % self._id 30 | r = self.delete(url) 31 | self.assertEqual(r.status_code, 204) 32 | response = self.client.get('/simpledoc') 33 | self.assertEqual(response.status_code, 200) 34 | items = response.get_json()[config.ITEMS] 35 | self.assertEqual(len(items), 1) 36 | self.assertEqual(items[0]['_id'], self._id2) 37 | 38 | def test_delete_resource(self): 39 | r = self.delete('/simpledoc') 40 | response = self.client.get('/simpledoc') 41 | self.assertEqual(response.status_code, 200) 42 | self.assertEqual(len(response.get_json()['_items']), 0) 43 | 44 | def test_delete_empty_resource(self): 45 | SimpleDoc.objects().delete() 46 | response = self.delete('/simpledoc') 47 | self.assertEqual(response.status_code, 204) 48 | 49 | def test_delete_unknown_item(self): 50 | url = '/simpledoc/%s' % 'abc' 51 | response = self.delete(url) 52 | self.assertEqual(response.status_code, 404) 53 | 54 | def test_delete_unknown_resource(self): 55 | response = self.delete('/unknown') 56 | self.assertEqual(response.status_code, 404) 57 | 58 | def test_delete_subresource_item(self): 59 | # create new resource and subresource 60 | s = SimpleDoc(a="Answer to everything", b=42).save() 61 | d = ComplexDoc(l=['a', 'b'], n=999, r=s).save() 62 | 63 | response = self.client.get('/simpledoc/%s/complexdoc/%s' % (s.id, d.id)) 64 | etag = response.get_json()[config.ETAG] 65 | headers = [('If-Match', etag)] 66 | 67 | # delete subresource 68 | del_url = '/simpledoc/%s/complexdoc/%s' % (s.id, d.id) 69 | response = self.client.delete(del_url, headers=headers) 70 | self.assertEqual(response.status_code, 204) 71 | # check, if really deleted 72 | response = self.client.get('/simpledoc/%s/complexdoc/%s' % (s.id, d.id)) 73 | self.assertEqual(response.status_code, 404) 74 | s.delete() 75 | 76 | def test_delete_subresource(self): 77 | # more subresources -> delete them all 78 | s = SimpleDoc(a="James Bond", b=7).save() 79 | c1 = ComplexDoc(l=['p', 'q', 'r'], n=1, r=s).save() 80 | c2 = ComplexDoc(l=['s', 't', 'u'], n=2, r=s).save() 81 | 82 | # delete subresources 83 | del_url = '/simpledoc/%s/complexdoc' % s.id 84 | response = self.client.delete(del_url) 85 | self.assertEqual(response.status_code, 204) 86 | # check, if really deleted 87 | response = self.client.get('/simpledoc/%s/complexdoc' % s.id) 88 | json_data = response.get_json() 89 | self.assertEqual(json_data[config.ITEMS], []) 90 | # cleanup 91 | s.delete() 92 | -------------------------------------------------------------------------------- /tests/test_fields.py: -------------------------------------------------------------------------------- 1 | 2 | import json 3 | import uuid 4 | import unittest 5 | 6 | from eve.exceptions import SchemaException 7 | from eve.utils import config 8 | 9 | from tests import (BaseTest, Eve, SimpleDoc, ComplexDoc, Inner, LimitedDoc, 10 | WrongDoc, FieldsDoc, PrimaryKeyDoc, SETTINGS) 11 | from eve_mongoengine._compat import iteritems, long 12 | 13 | class TestFields(BaseTest, unittest.TestCase): 14 | 15 | def tearDown(self): 16 | FieldsDoc.objects.delete() 17 | 18 | def _fixture_template(self, data_ok, expected=None, data_fail=None, msg=None): 19 | d = FieldsDoc(**data_ok).save() 20 | if expected is None: 21 | expected = data_ok 22 | response = self.client.get('/fieldsdoc') 23 | json_data = response.get_json()[config.ITEMS][0] 24 | try: 25 | for key, value in iteritems(expected): 26 | self.assertEqual(json_data[key], value) 27 | finally: 28 | d.delete() 29 | if not data_fail: 30 | return 31 | # post 32 | response = self.client.post('/fieldsdoc/', 33 | data=json.dumps(data_fail), 34 | content_type='application/json') 35 | json_data = response.get_json() 36 | self.assertEqual(json_data[config.STATUS], "ERR") 37 | self.assertEqual(json_data[config.ISSUES], msg) 38 | 39 | def test_url_field(self): 40 | self._fixture_template(data_ok={'a':'http://google.com'}, 41 | data_fail={'a':'foobar'}, 42 | msg={'a': "ValidationError (FieldsDoc:None) (Invalid"\ 43 | " URL: foobar: ['a'])"}) 44 | 45 | def test_email_field(self): 46 | self._fixture_template(data_ok={'b':'heller.stanislav@gmail.com'}, 47 | data_fail={'b':'invalid@email'}, 48 | msg={'b': "ValidationError (FieldsDoc:None) (Invalid"\ 49 | " Mail-address: invalid@email: ['b'])"}) 50 | 51 | def test_uuid_field(self): 52 | self._fixture_template(data_ok={'g': 'ddbec64f-3178-43ed-aee3-1455968f24ab'}, 53 | data_fail={'g': 'foo-bar-baz'}, 54 | msg={'g': "ValidationError (FieldsDoc:None) (Could "\ 55 | "not convert to UUID: badly formed hexade"\ 56 | "cimal UUID string: ['g'])"}) 57 | 58 | def test_long_field(self): 59 | self._fixture_template(data_ok={'c': long(999)}) 60 | 61 | def test_decimal_field(self): 62 | self._fixture_template(data_ok={'d': 10.34}) 63 | 64 | def test_sortedlist_field(self): 65 | self._fixture_template(data_ok={'e':[4,1,8]}, expected={'e': [1,4,8]}) 66 | 67 | def test_map_field(self): 68 | self._fixture_template(data_ok={'f': {'x': 'foo', 'y': 'bar'}}, 69 | data_fail={'f': {'x': 1}}, 70 | msg={'f': "ValidationError (FieldsDoc:None) "\ 71 | "(x.StringField only accepts string "\ 72 | "values: ['f'])"}) 73 | 74 | 75 | def test_embedded_document_field(self): 76 | i = Inner(a="hihi", b=123) 77 | d = ComplexDoc(i=i) 78 | d.save() 79 | response = self.client.get('/complexdoc') 80 | json_data = response.get_json()[config.ITEMS][0] 81 | self.assertDictEqual(json_data['i'], {'a':"hihi", 'b':123}) 82 | d.delete() 83 | # POST request 84 | response = self.client.post('/complexdoc/', 85 | data='{"i": {"a": "xaxa", "b":-555}}', 86 | content_type='application/json') 87 | self.assertEqual(response.get_json()[config.STATUS], "OK") 88 | 89 | response = self.client.get('/complexdoc') 90 | json_data = response.get_json()[config.ITEMS][0] 91 | self.assertDictEqual(json_data['i'], {'a':"xaxa", 'b':-555}) 92 | 93 | ComplexDoc.objects[0].delete() 94 | response = self.client.post('/complexdoc/', 95 | data='{"i": {"a": "bar", "b": "baz"}}', 96 | content_type='application/json') 97 | json_data = response.get_json() 98 | self.assertEqual(json_data[config.STATUS], "ERR") 99 | self.assertIn('i', json_data[config.ISSUES]) 100 | self.assertIn('b', json_data[config.ISSUES]['i']) 101 | self.assertEqual(json_data[config.ISSUES]['i']['b'], 'must be of integer type') 102 | 103 | def test_embedded_in_list(self): 104 | # that's a tuff one 105 | i1 = Inner(a="foo", b=789) 106 | i2 = Inner(a="baz", b=456) 107 | d = ComplexDoc(o=[i1, i2]) 108 | d.save() 109 | response = self.client.get('/complexdoc') 110 | try: 111 | json_data = response.get_json()[config.ITEMS][0] 112 | self.assertListEqual(json_data['o'], [{'a':"foo", 'b':789},{'a':"baz", 'b':456}]) 113 | finally: 114 | d.delete() 115 | 116 | def test_dynamic_field(self): 117 | d = ComplexDoc(n=789) 118 | d.save() 119 | response = self.client.get('/complexdoc') 120 | try: 121 | json_data = response.get_json()[config.ITEMS][0] 122 | self.assertEqual(json_data['n'], 789) 123 | finally: 124 | # cleanup 125 | d.delete() 126 | 127 | def test_dict_field(self): 128 | d = ComplexDoc(d={'g':'good', 'h':'hoorai'}) 129 | d.save() 130 | response = self.client.get('/complexdoc') 131 | try: 132 | json_data = response.get_json()[config.ITEMS][0] 133 | self.assertDictEqual(json_data['d'], {'g':'good', 'h':'hoorai'}) 134 | finally: 135 | # cleanup 136 | d.delete() 137 | 138 | def test_reference_field(self): 139 | s = SimpleDoc(a="samurai", b=911) 140 | s.save() 141 | d = ComplexDoc(r=s) 142 | d.save() 143 | response = self.client.get('/complexdoc') 144 | try: 145 | json_data = response.get_json()[config.ITEMS][0] 146 | self.assertEqual(json_data['r'], str(s.id)) 147 | finally: 148 | # cleanup 149 | d.delete() 150 | s.delete() 151 | 152 | def test_db_field_name(self): 153 | # test if eve returns fields named like in db, not in python 154 | response = self.client.post('/fieldsdoc/', 155 | data='{"longFieldName": "hello"}', 156 | content_type='application/json') 157 | json_data = response.get_json() 158 | self.assertEqual(response.status_code, 201) 159 | self.assertEqual(json_data[config.STATUS], "OK") 160 | response = self.client.get('/fieldsdoc') 161 | json_data = response.get_json()[config.ITEMS][0] 162 | self.assertIn('longFieldName', json_data) 163 | self.assertEqual(json_data['longFieldName'], "hello") 164 | FieldsDoc.objects.delete() 165 | # the same, but through mongoengine 166 | FieldsDoc(n="hi").save() 167 | response = self.client.get('/fieldsdoc') 168 | json_data = response.get_json()[config.ITEMS][0] 169 | self.assertIn('longFieldName', json_data) 170 | self.assertEqual(json_data['longFieldName'], "hi") 171 | 172 | def test_non_standard_field(self): 173 | # tests FancyStringField -> it should be considered as StringField 174 | _type = self.app.config['DOMAIN']['fieldsdoc']['schema']['o']['type'] 175 | self.assertEqual(_type, 'string') 176 | self._fixture_template(data_ok={'o':'Apples and oranges'}, 177 | data_fail={'o': 1}, 178 | msg={'o': "must be of string type"}) 179 | 180 | def test_custom_primary_key(self): 181 | # test case, when custom id_field (primary key) is set. 182 | # XXX: datalayer should handle this instead of relying on default _id, 183 | # but eve does not support it :(, we have to raise exception. 184 | with self.assertRaises(SchemaException): 185 | self.ext.add_model(PrimaryKeyDoc) 186 | -------------------------------------------------------------------------------- /tests/test_get.py: -------------------------------------------------------------------------------- 1 | 2 | import uuid 3 | import unittest 4 | from operator import attrgetter 5 | from eve_mongoengine import EveMongoengine 6 | from tests import (BaseTest, Eve, SimpleDoc, ComplexDoc, Inner, LimitedDoc, 7 | WrongDoc, NonStructuredDoc, Inherited, SETTINGS) 8 | from eve.utils import config 9 | 10 | class TestHttpGet(BaseTest, unittest.TestCase): 11 | 12 | def test_find_one(self): 13 | d = SimpleDoc(a='Tom', b=223).save() 14 | response = self.client.get('/simpledoc/%s' % d.id) 15 | # has to return one record 16 | json_data = response.get_json() 17 | self.assertIn(config.LAST_UPDATED, json_data) 18 | self.assertIn(config.DATE_CREATED, json_data) 19 | self.assertEqual(json_data['_id'], str(d.id)) 20 | self.assertEqual(json_data['a'], 'Tom') 21 | self.assertEqual(json_data['b'], 223) 22 | d.delete() 23 | 24 | def test_find_one_projection(self): 25 | d = SimpleDoc(a='Tom', b=223).save() 26 | response = self.client.get('/simpledoc/%s?projection={"a":1}' % d.id) 27 | # has to return one record 28 | json_data = response.get_json() 29 | self.assertIn(config.LAST_UPDATED, json_data) 30 | self.assertIn(config.DATE_CREATED, json_data) 31 | self.assertNotIn('b', json_data) 32 | self.assertEqual(json_data['_id'], str(d.id)) 33 | self.assertEqual(json_data['a'], 'Tom') 34 | response = self.client.get('/simpledoc/%s?projection={"a":0}' % d.id) 35 | json_data = response.get_json() 36 | self.assertIn(config.LAST_UPDATED, json_data) 37 | self.assertIn(config.DATE_CREATED, json_data) 38 | self.assertIn('b', json_data) 39 | self.assertNotIn('a', json_data) 40 | self.assertEqual(json_data['_id'], str(d.id)) 41 | d.delete() 42 | 43 | def test_find_one_nonexisting(self): 44 | response = self.client.get('/simpledoc/abcdef') 45 | self.assertEqual(response.status_code, 404) 46 | 47 | def test_projection_on_non_structured_doc(self): 48 | d = NonStructuredDoc(new_york="great").save() 49 | response = self.client.get('/nonstructureddoc/%s' % str(d.id)) 50 | self.assertEqual(response.status_code, 200) 51 | json_data = response.get_json() 52 | self.assertIn('NewYork', json_data) 53 | self.assertEqual(json_data['NewYork'], 'great') 54 | 55 | def test_datasource_projection(self): 56 | SETTINGS['DOMAIN'] = {'eve-mongoengine':{}} 57 | app = Eve(settings=SETTINGS) 58 | app.debug = True 59 | ext = EveMongoengine(app) 60 | ext.add_model(SimpleDoc, datasource={'projection': {'b': 0}}) 61 | client = app.test_client() 62 | d = SimpleDoc(a='Tom', b=223).save() 63 | response = client.get('/simpledoc/%s' % d.id) 64 | try: 65 | self.assertNotIn('b', response.get_json().keys()) 66 | # here it should return the field, but sadly, does not 67 | #response = client.get('/simpledoc/%s?projection={"b":1}' % d.id) 68 | #self.assertIn('b', response.get_json().keys()) 69 | finally: 70 | d.delete() 71 | 72 | def test_find_all(self): 73 | _all = [] 74 | for data in ({'a': "Hello", 'b':1}, 75 | {'a': "Hi", 'b': 2}, 76 | {'a': "Seeya", 'b': 3}): 77 | d = SimpleDoc(**data).save() 78 | _all.append(d) 79 | response = self.client.get('/simpledoc') 80 | self.assertEqual(response.status_code, 200) 81 | data = response.get_json() 82 | s = set([item['a'] for item in data['_items']]) 83 | self.assertSetEqual(set(['Hello', 'Hi', 'Seeya']), s) 84 | # delete records 85 | for d in _all: 86 | d.delete() 87 | 88 | def test_find_all_projection(self): 89 | d = SimpleDoc(a='Tom', b=223).save() 90 | response = self.client.get('/simpledoc?projection={"a": 1}') 91 | self.assertNotIn('b', response.get_json()['_items'][0]) 92 | response = self.client.get('/simpledoc?projection={"a": 1, "b": 1}') 93 | data = response.get_json()['_items'][0] 94 | self.assertIn('b', data) 95 | self.assertIn('a', data) 96 | response = self.client.get('/simpledoc?projection={"a": 0}') 97 | data = response.get_json()['_items'][0] 98 | self.assertIn('b', data) 99 | self.assertNotIn('a', data) 100 | d.delete() 101 | 102 | def test_find_all_pagination(self): 103 | self.skipTest("Not implemented yet.") 104 | 105 | def test_find_all_sorting(self): 106 | d = SimpleDoc(a='abz', b=3).save() 107 | d2 = SimpleDoc(a='abc', b=-7).save() 108 | response = self.client.get('/simpledoc?sort={"a":1}') 109 | json_data = response.get_json() 110 | real = [x['a'] for x in json_data['_items']] 111 | expected = ['abc', 'abz'] 112 | try: 113 | self.assertListEqual(real, expected) 114 | except Exception as e: 115 | # reset 116 | d.delete() 117 | d2.delete() 118 | raise 119 | 120 | response = self.client.get('/simpledoc?sort={"b":-1}') 121 | json_data = response.get_json() 122 | real = [x['b'] for x in json_data['_items']] 123 | expected = [3, -7] 124 | try: 125 | self.assertListEqual(real, expected) 126 | finally: 127 | d.delete() 128 | d2.delete() 129 | 130 | def test_find_all_default_sort(self): 131 | s = self.app.config['DOMAIN']['simpledoc']['datasource'] 132 | d = SimpleDoc(a='abz', b=3).save() 133 | d2 = SimpleDoc(a='abc', b=-7).save() 134 | 135 | # set default sort to 'b', desc. 136 | if 'default_sort' in s: 137 | default = s['default_sort'] 138 | else: 139 | default = [] 140 | s['default_sort'] = [('b', -1)] 141 | self.app.set_defaults() 142 | response = self.client.get('/simpledoc') 143 | json_data = response.get_json() 144 | real = [x['b'] for x in json_data['_items']] 145 | expected = [3, -7] 146 | try: 147 | self.assertListEqual(real, expected) 148 | except Exception as e: 149 | # reset 150 | s['default_sort'] = default 151 | d.delete() 152 | d2.delete() 153 | raise 154 | 155 | # set default sort to 'b', asc. 156 | s['default_sort'] = [('b', 1)] 157 | self.app.set_defaults() 158 | response = self.client.get('/simpledoc') 159 | json_data = response.get_json() 160 | real = [x['b'] for x in json_data['_items']] 161 | expected = [-7, 3] 162 | try: 163 | self.assertListEqual(real, expected) 164 | finally: 165 | # reset 166 | s['default_sort'] = default 167 | d.delete() 168 | d2.delete() 169 | 170 | def test_find_all_filtering(self): 171 | d = SimpleDoc(a='x', b=987).save() 172 | d2 = SimpleDoc(a='y', b=123).save() 173 | response = self.client.get('/simpledoc?where={"a": "y"}') 174 | json_data = response.get_json() 175 | try: 176 | self.assertEqual(len(json_data['_items']), 1) 177 | self.assertEqual(json_data['_items'][0]['b'], 123) 178 | finally: 179 | d.delete() 180 | d2.delete() 181 | 182 | def test_etag_in_item_and_resource(self): 183 | # etag of some entity has to be the same when fetching one item compared 184 | # to etag of part of feed (resource) 185 | d = ComplexDoc().save() 186 | feed = self.client.get('/complexdoc/').get_json() 187 | item = self.client.get('/complexdoc/%s' % d.id).get_json() 188 | try: 189 | self.assertEqual(feed[config.ITEMS][0][config.ETAG], item[config.ETAG]) 190 | finally: 191 | d.delete() 192 | 193 | def test_embedded_resource_serialization(self): 194 | s = SimpleDoc(a="Answer to everything", b=42).save() 195 | d = ComplexDoc(r=s).save() 196 | response = self.client.get('/complexdoc?embedded={"r":1}') 197 | json_data = response.get_json() 198 | expected = {'a': "Answer to everything", 'b': 42} 199 | try: 200 | emb = json_data['_items'][0]['r'] 201 | self.assertEqual(emb['a'], expected['a']) 202 | self.assertEqual(emb['b'], expected['b']) 203 | self.assertIn(config.DATE_CREATED, emb) 204 | self.assertIn(config.LAST_UPDATED, emb) 205 | finally: 206 | d.delete() 207 | s.delete() 208 | 209 | def test_uppercase_resource_names(self): 210 | # Sanity Check: the Default Setting is Uppercase Off 211 | response = self.client.get('/SimpleDoc') 212 | self.assertEqual(response.status_code, 404) 213 | 214 | # Create a new Eve App with settings to allow Uppercase Resource Names 215 | # in the URI. 216 | app = Eve(settings=SETTINGS) 217 | app.debug = True 218 | ext = EveMongoengine(app) 219 | ext.add_model(SimpleDoc, lowercase=False) 220 | client = app.test_client() 221 | d = SimpleDoc(a='Tom', b=223).save() 222 | 223 | # Sanity Check: Lowercase is Disabled 224 | response = client.get('/simpledoc/') 225 | self.assertEqual(response.status_code, 404) 226 | 227 | # Use the Uppercase Resource Name 228 | response = client.get('/SimpleDoc/') 229 | self.assertEqual(response.status_code, 200) 230 | json_data = response.get_json() 231 | expected_url = json_data[config.LINKS]['self']['href'] 232 | if ':' in expected_url: 233 | expected_url = '/' + '/'.join( expected_url.split('/')[1:] ) 234 | self.assertTrue('SimpleDoc' in expected_url) 235 | 236 | 237 | 238 | 239 | def test_get_subresource(self): 240 | s = SimpleDoc(a="Answer to everything", b=42).save() 241 | d = ComplexDoc(l=['a', 'b'], r=s).save() 242 | d2 = ComplexDoc(l=['c', 'd'], r=s).save() 243 | response = self.client.get('/simpledoc/%s/complexdoc' % s.id) 244 | self.assertEqual(response.status_code, 200) 245 | json_data = response.get_json() 246 | self.assertEqual(len(json_data['_items']), 2) 247 | real = [x['l'] for x in json_data['_items']] 248 | self.assertEqual(real, [['a', 'b'], ['c', 'd']]) 249 | 250 | 251 | def test_inherited(self): 252 | # tests if inherited documents behave the same way 253 | i = Inherited(a='Answer', b=42, c='BarBaz').save() 254 | response = self.client.get('/inherited/%s/' % i.id) 255 | self.assertIn('C', response.get_json()) 256 | self.assertEqual(response.status_code, 200) 257 | # cannot throw mongoengine.LookUpError! 258 | response = self.client.get('/inherited/') 259 | self.assertEqual(response.status_code, 200) 260 | -------------------------------------------------------------------------------- /tests/test_mongoengine_fix.py: -------------------------------------------------------------------------------- 1 | 2 | from datetime import datetime 3 | import unittest 4 | 5 | from mongoengine import Document, StringField, IntField 6 | 7 | from eve.exceptions import SchemaException 8 | from eve.utils import str_to_date, config 9 | from eve_mongoengine import EveMongoengine 10 | 11 | from tests import BaseTest, Eve, SimpleDoc, ComplexDoc, LimitedDoc, WrongDoc, SETTINGS 12 | 13 | class TestMongoengineFix(unittest.TestCase): 14 | """ 15 | Test fixing mongoengine classes for Eve's purposes. 16 | """ 17 | def create_app(self, *models): 18 | app = Eve(settings=SETTINGS) 19 | app.debug = True 20 | ext = EveMongoengine(app) 21 | ext.add_model(models) 22 | return app.test_client() 23 | 24 | def assertDateTimeAlmostEqual(self, d1, d2, precission='minute'): 25 | """ 26 | Used for testing datetime, which cannot (or we do not want to) be 27 | injected into tested object. Omits second and microsecond part. 28 | """ 29 | self.assertEqual(d1.year, d2.year) 30 | self.assertEqual(d1.month, d2.month) 31 | self.assertEqual(d1.day, d2.day) 32 | self.assertEqual(d1.hour, d2.hour) 33 | self.assertEqual(d1.minute, d2.minute) 34 | 35 | def _test_default_values(self, app, cls, updated_name='updated', 36 | created_name='created'): 37 | # test updated and created fields if they are correctly generated 38 | now = datetime.utcnow() 39 | d = cls(a="xyz", b=29) 40 | updated = getattr(d, updated_name) 41 | created = getattr(d, created_name) 42 | self.assertEqual(type(updated), datetime) 43 | self.assertEqual(type(created), datetime) 44 | self.assertDateTimeAlmostEqual(updated, now) 45 | self.assertDateTimeAlmostEqual(created, now) 46 | d.save() 47 | # test real returned values 48 | json_data = app.get('/simpledoc/').get_json() 49 | created_attr = app.application.config['DATE_CREATED'] 50 | created_str = json_data[config.ITEMS][0][created_attr] 51 | date_created = str_to_date(created_str) 52 | self.assertDateTimeAlmostEqual(now, date_created) 53 | d.delete() 54 | 55 | def test_default_values(self): 56 | app = self.create_app(SimpleDoc) 57 | self.assertEqual(SimpleDoc._db_field_map['updated'], '_updated') 58 | self.assertEqual(SimpleDoc._reverse_db_field_map['_updated'], 'updated') 59 | self.assertEqual(SimpleDoc._db_field_map['created'], '_created') 60 | self.assertEqual(SimpleDoc._reverse_db_field_map['_created'], 'created') 61 | self._test_default_values(app, SimpleDoc) 62 | 63 | def test_wrong_doc(self): 64 | with self.assertRaises(TypeError): 65 | self.create_app(WrongDoc) 66 | 67 | def test_nondefault_last_updated_field(self): 68 | # redefine to get entirely new class 69 | class SimpleDoc(Document): 70 | a = StringField() 71 | b = IntField() 72 | sett = SETTINGS.copy() 73 | sett['LAST_UPDATED'] = 'last_change' 74 | app = Eve(settings=sett) 75 | app.debug = True 76 | ext = EveMongoengine(app) 77 | ext.add_model(SimpleDoc) 78 | client = app.test_client() 79 | with app.app_context(): # to get current app's config 80 | self._test_default_values(client, SimpleDoc, updated_name='last_change') 81 | 82 | def test_nondefault_date_created_field(self): 83 | # redefine to get entirely new class 84 | class SimpleDoc(Document): 85 | a = StringField() 86 | b = IntField() 87 | sett = SETTINGS.copy() 88 | sett['DATE_CREATED'] = 'created_at' 89 | app = Eve(settings=sett) 90 | app.debug = True 91 | ext = EveMongoengine(app) 92 | ext.add_model(SimpleDoc) 93 | app = app.test_client() 94 | self._test_default_values(app, SimpleDoc, created_name='created_at') 95 | -------------------------------------------------------------------------------- /tests/test_patch.py: -------------------------------------------------------------------------------- 1 | 2 | from bson import ObjectId 3 | import json 4 | import time 5 | import unittest 6 | from distutils.version import LooseVersion 7 | 8 | from eve.utils import config 9 | from eve import __version__ 10 | EVE_VERSION = LooseVersion(__version__) 11 | 12 | from tests import BaseTest, SimpleDoc, ComplexDoc, FieldsDoc 13 | 14 | 15 | def post_simple_item(f): 16 | def wrapper(self): 17 | response = self.client.post('/simpledoc/', 18 | data='{"a": "jimmy", "b": 23}', 19 | content_type='application/json') 20 | json_data = response.get_json() 21 | self._id = json_data[config.ID_FIELD] 22 | self.url = '/simpledoc/%s' % self._id # json_data[config.ID_FIELD] 23 | #response = self.client.get(self.url).get_json() 24 | self.etag = json_data[config.ETAG] 25 | self.updated = json_data[config.LAST_UPDATED] 26 | try: 27 | f(self) 28 | finally: 29 | SimpleDoc.objects().delete() 30 | return wrapper 31 | 32 | def post_complex_item(f): 33 | def wrapper(self): 34 | payload = '{"i": {"a": "hello"}, "d": {"x": null}, "l": ["m", "n"], '+\ 35 | '"o": [{"a":"hi"},{"b":9}], "p": [{"ll": ["q", "w"]}]}' 36 | response = self.client.post('/complexdoc/', 37 | data=payload, 38 | content_type='application/json') 39 | json_data = response.get_json() 40 | self._id = json_data[config.ID_FIELD] 41 | self.url = '/complexdoc/%s' % json_data[config.ID_FIELD] 42 | self.etag = json_data[config.ETAG] 43 | # check if etags are okay 44 | self.assertEqual(self.client.get(self.url).get_json()[config.ETAG], self.etag) 45 | #self._id = response[config.ID_FIELD] 46 | self.updated = json_data[config.LAST_UPDATED] 47 | try: 48 | f(self) 49 | finally: 50 | ComplexDoc.objects().delete() 51 | return wrapper 52 | 53 | 54 | class TestHttpPatch(BaseTest, unittest.TestCase): 55 | 56 | def do_patch(self, url=None, data=None, headers=None): 57 | if url is None: 58 | url = self.url 59 | if headers is None: 60 | headers=[('If-Match', self.etag)] 61 | return self.client.patch(url, data=data, 62 | content_type='application/json', 63 | headers=headers) 64 | 65 | def assert_correct_etag(self, patch_response): 66 | # Ensure clean response. 67 | self.assertLess(patch_response.status_code, 300, 68 | "PATCH request failed. Returned {}.".format( 69 | patch_response.data 70 | )) 71 | 72 | etag = patch_response.get_json()[config.ETAG] 73 | get_resp = self.client.get(self.url).get_json() 74 | get_etag = get_resp[config.ETAG] 75 | self.assertEqual(etag, get_etag) 76 | 77 | @post_simple_item 78 | def test_patch_overwrite_all(self): 79 | patch_response = self.do_patch(data='{"a": "greg", "b": 300}') 80 | self.assert_correct_etag(patch_response) 81 | response = self.client.get(self.url).get_json() 82 | self.assertIn('a', response) 83 | self.assertEqual(response['a'], "greg") 84 | self.assertIn('b', response) 85 | self.assertEqual(response['b'], 300) 86 | 87 | @post_simple_item 88 | def test_patch_overwrite_subset(self): 89 | # test what was really updated 90 | raw = SimpleDoc._get_collection().find_one({"_id": ObjectId(self._id)}) 91 | response = self.do_patch(data='{"a": "greg"}') 92 | self.assert_correct_etag(response) 93 | expected = dict(raw) 94 | expected['a'] = 'greg' 95 | real = SimpleDoc._get_collection().find_one({"_id": ObjectId(self._id)}) 96 | self.assertDictEqual(real, expected) 97 | # test if GET response returns corrent response 98 | response = self.client.get(self.url).get_json() 99 | self.assertIn('a', response) 100 | self.assertEqual(response['a'], "greg") 101 | self.assertIn('b', response) 102 | self.assertEqual(response['b'], 23) 103 | 104 | @post_complex_item 105 | def test_patch_dict_field(self): 106 | test = ComplexDoc.objects[0] 107 | self.assertListEqual(test.l, ['m', 'n']) 108 | self.assertEqual(test.d['x'], None) 109 | self.assertEqual(test.i.a, "hello") 110 | # do PATCH 111 | response = self.do_patch(data='{"d": {"x": "789"}}') 112 | self.assert_correct_etag(response) 113 | real = ComplexDoc.objects[0] 114 | self.assertEqual(real.d['x'], "789") 115 | self.assertListEqual(real.l, ['m', 'n']) 116 | self.assertEqual(real.i.a, "hello") 117 | 118 | @post_complex_item 119 | def test_patch_embedded_document(self): 120 | self.assertEqual(ComplexDoc.objects[0].i.a, "hello") 121 | response = self.do_patch(data='{"i": {"a": "bye"}}') 122 | self.assert_correct_etag(response) 123 | self.assertEqual(ComplexDoc.objects[0].i.a, "bye") 124 | 125 | @post_complex_item 126 | def test_patch_embedded_document_in_list(self): 127 | self.assertEqual(ComplexDoc.objects[0].o[0].a, "hi") 128 | self.assertEqual(len(ComplexDoc.objects[0].o), 2) 129 | response = self.do_patch(data='{"o": [{"a": "bye"}]}') 130 | self.assert_correct_etag(response) 131 | self.assertEqual(ComplexDoc.objects[0].o[0].a, "bye") 132 | self.assertEqual(len(ComplexDoc.objects[0].o), 1) 133 | 134 | @post_complex_item 135 | def test_patch_list(self): 136 | self.assertEqual(ComplexDoc.objects[0].l, ["m", "n"]) 137 | # full one 138 | response = self.do_patch(data='{"l": ["n"]}') 139 | self.assert_correct_etag(response) 140 | self.etag = response.get_json()[config.ETAG] 141 | doc = ComplexDoc.objects[0] 142 | self.assertEqual(doc.l, ["n"]) 143 | self.assertEqual(doc.i.a, "hello") 144 | 145 | @post_complex_item 146 | def test_patch_empty_list(self): 147 | """ 148 | Sadly, in default mode (use_atomic_update_for_patch=True) this raises 149 | error and there is no way (except for patching eve) to workaround this 150 | without doing another mongo fetch (and break atomicity), which is the 151 | way how it is done. 152 | """ 153 | # empty one 154 | response = self.do_patch(data='{"l": []}') 155 | self.assert_correct_etag(response) 156 | doc = ComplexDoc.objects[0] 157 | self.assertEqual(doc.l, []) 158 | self.assertEqual(doc.i.a, "hello") 159 | 160 | @post_complex_item 161 | @unittest.skipIf(EVE_VERSION >= LooseVersion("0.5"), "Eve 0.5 Bug") 162 | def test_patch_empty_list_and_empty_dict(self): 163 | # Empty List and Empty Dictionary 164 | response = self.do_patch(data='{"l": [], "d": {}}') 165 | self.assert_correct_etag(response) 166 | doc = ComplexDoc.objects[0] 167 | self.assertEqual(doc.l, []) 168 | self.assertEqual(doc.d, {}) 169 | self.assertEqual(doc.i.a, "hello") 170 | 171 | @post_complex_item 172 | def test_patch_list_in_list(self): 173 | self.assertEqual(ComplexDoc.objects[0].p[0].ll, ["q", "w"]) 174 | response = self.do_patch(data='{"p": [{"ll": ["y"]}]}') 175 | self.assert_correct_etag(response) 176 | self.etag = response.get_json()[config.ETAG] 177 | doc = ComplexDoc.objects[0] 178 | self.assertEqual(doc.p[0].ll, ["y"]) 179 | 180 | @post_complex_item 181 | def test_patch_empty_list_in_list(self): 182 | self.assertEqual(ComplexDoc.objects[0].p[0].ll, ["q", "w"]) 183 | response = self.do_patch(data='{"p": [{"ll": []}]}') 184 | self.assert_correct_etag(response) 185 | self.etag = response.get_json()[config.ETAG] 186 | doc = ComplexDoc.objects[0] 187 | self.assertEqual(doc.p[0].ll, []) 188 | 189 | def test_patch_subresource(self): 190 | # create new resource and subresource 191 | s = SimpleDoc(a="Answer to everything", b=42).save() 192 | d = ComplexDoc(l=['a', 'b'], n=999, r=s).save() 193 | 194 | response = self.client.get('/simpledoc/%s/complexdoc/%s' % (s.id, d.id)) 195 | etag = response.get_json()[config.ETAG] 196 | headers = [('If-Match', etag)] 197 | 198 | # patch document 199 | patch_data = {'l': ['x', 'y', 'z'], 'r': str(s.id)} 200 | patch_url = '/simpledoc/%s/complexdoc/%s' % (s.id, d.id) 201 | response = self.client.patch(patch_url, data=json.dumps(patch_data), 202 | content_type='application/json', headers=headers) 203 | self.assertEqual(response.status_code, 200) 204 | resp_json = response.get_json() 205 | self.assertEqual(resp_json[config.STATUS], "OK") 206 | 207 | # check, if really edited 208 | response = self.client.get('/simpledoc/%s/complexdoc/%s' % (s.id, d.id)) 209 | json_data = response.get_json() 210 | self.assertListEqual(json_data['l'], ['x', 'y', 'z']) 211 | self.assertEqual(json_data['n'], 999) 212 | 213 | # cleanup 214 | s.delete() 215 | d.delete() 216 | 217 | def test_patch_field_with_different_dbfield(self): 218 | # tests patching field whith has mongoengine's db_field specified 219 | # and different from python field name 220 | s = FieldsDoc(n="Hello").save() 221 | response = self.client.get('/fieldsdoc/%s' % s.id) 222 | etag = response.get_json()[config.ETAG] 223 | headers = [('If-Match', etag)] 224 | 225 | # patch document 226 | patch_data = {'longFieldName': 'Howdy'} 227 | patch_url = '/fieldsdoc/%s' % s.id 228 | response = self.client.patch(patch_url, data=json.dumps(patch_data), 229 | content_type='application/json', headers=headers) 230 | self.assertEqual(response.status_code, 200) 231 | resp_json = response.get_json() 232 | self.assertEqual(resp_json[config.STATUS], "OK") 233 | 234 | @post_simple_item 235 | def test_update_date_consistency(self): 236 | # tests if _updated is really updated when PATCHing resource 237 | updated = self.client.get(self.url).get_json()[config.LAST_UPDATED] 238 | time.sleep(1) 239 | s = SimpleDoc.objects.get() 240 | updated_before_patch = s.updated 241 | s.a = "bob" 242 | s.save() 243 | updated_after_patch = s.updated 244 | self.assertNotEqual(updated_before_patch, updated_after_patch) 245 | delta = updated_after_patch - updated_before_patch 246 | self.assertGreater(delta.seconds, 0) 247 | 248 | 249 | class TestHttpPatchUsingSaveMethod(TestHttpPatch): 250 | @classmethod 251 | def setUpClass(cls): 252 | BaseTest.setUpClass() 253 | cls.app.data.mongoengine_options['use_atomic_update_for_patch'] = False 254 | 255 | @classmethod 256 | def tearDownClass(cls): 257 | BaseTest.tearDownClass() 258 | cls.app.data.mongoengine_options['use_atomic_update_for_patch'] = True 259 | -------------------------------------------------------------------------------- /tests/test_post.py: -------------------------------------------------------------------------------- 1 | 2 | from datetime import datetime 3 | import unittest 4 | import json 5 | from distutils.version import LooseVersion 6 | 7 | from eve_mongoengine import EveMongoengine 8 | 9 | from eve.utils import config 10 | 11 | from tests import ( 12 | BaseTest, Eve, SimpleDoc, ComplexDoc, LimitedDoc, 13 | WrongDoc, HawkeyDoc, SETTINGS 14 | ) 15 | 16 | # Starting with Eve 0.5 - Validation errors response codes are configurable. 17 | try: 18 | POST_VALIDATION_ERROR_CODE = config.VALIDATION_ERROR_STATUS 19 | except AttributeError: 20 | POST_VALIDATION_ERROR_CODE = 422 21 | 22 | 23 | class TestHttpPost(BaseTest, unittest.TestCase): 24 | 25 | def test_post_simple(self): 26 | now = datetime.now() 27 | response = self.client.post('/simpledoc/', 28 | data='{"a": "jimmy", "b": 23}', 29 | content_type='application/json') 30 | self.assertEqual(response.status_code, 201) 31 | post_data = response.get_json() 32 | self.assertEqual(post_data[config.STATUS], "OK") 33 | _id = post_data['_id'] 34 | response = self.client.get('/simpledoc/%s' % _id) 35 | get_data = response.get_json() 36 | # updated field must match 37 | self.assertEqual(post_data[config.LAST_UPDATED], get_data[config.LAST_UPDATED]) 38 | 39 | def test_post_invalid_schema_type(self): 40 | response = self.client.post('/simpledoc/', 41 | data='{"a":123}', 42 | content_type='application/json') 43 | self.assertEqual(response.status_code, POST_VALIDATION_ERROR_CODE) 44 | json_data = response.get_json() 45 | self.assertIn(config.STATUS, json_data) 46 | self.assertEqual(json_data[config.STATUS], "ERR") 47 | self.assertDictEqual(json_data[config.ISSUES], {'a': "must be of string type"}) 48 | 49 | def test_post_invalid_schema_limits(self): 50 | # break min_length 51 | response = self.client.post('/limiteddoc/', 52 | data='{"a": "hi", "b": "ho", "c": "x", "d": "<10 chars", "e": "<10 chars", "g": "val1"}', 53 | content_type='application/json') 54 | self.assertEqual(response.status_code, POST_VALIDATION_ERROR_CODE) 55 | json_data = response.get_json() 56 | self.assertDictEqual(json_data[config.ISSUES], {'e': "min length is 10"}) 57 | # break max_length 58 | response = self.client.post('/limiteddoc/', 59 | data='{"a": "hi", "b": "ho", "c": "x", "d": "string > 10 chars", "e": "some very long text", "g": "val2"}', 60 | content_type='application/json') 61 | self.assertEqual(response.status_code, POST_VALIDATION_ERROR_CODE) 62 | json_data = response.get_json() 63 | self.assertDictEqual(json_data[config.ISSUES], {'d': "max length is 10"}) 64 | 65 | 66 | def test_post_invalid_schema_required(self): 67 | response = self.client.post('/limiteddoc/', 68 | data='{"b": "ho", "c": "x"}', 69 | content_type='application/json') 70 | self.assertEqual(response.status_code, POST_VALIDATION_ERROR_CODE) 71 | json_data = response.get_json() 72 | self.assertDictEqual(json_data[config.ISSUES], {'a': "required field"}) 73 | 74 | def test_post_invalid_schema_choice(self): 75 | response = self.client.post('/limiteddoc/', 76 | data='{"a": "hi", "b": "ho", "c": "a"}', 77 | content_type='application/json') 78 | self.assertEqual(response.status_code, POST_VALIDATION_ERROR_CODE) 79 | json_data = response.get_json() 80 | self.assertDictEqual(json_data[config.ISSUES], {'c': 'unallowed value a'}) 81 | response = self.client.post('/limiteddoc/', 82 | data='{"a": "hi", "b": "ho", "g": "val4"}', 83 | content_type='application/json') 84 | self.assertEqual(response.status_code, POST_VALIDATION_ERROR_CODE) 85 | json_data = response.get_json() 86 | self.assertDictEqual(json_data[config.ISSUES], {'g': 'unallowed value val4'}) 87 | response = self.client.post('/limiteddoc/', 88 | data='{"a": "hi", "b": "ho", "g": "test value 1"}', 89 | content_type='application/json') 90 | self.assertEqual(response.status_code, POST_VALIDATION_ERROR_CODE) 91 | json_data = response.get_json() 92 | self.assertDictEqual(json_data[config.ISSUES], {'g': 'unallowed value test value 1'}) 93 | 94 | def test_post_invalid_schema_unique(self): 95 | response = self.client.post('/limiteddoc/', 96 | data='{"a": "hi", "b": "ho"}', 97 | content_type='application/json') 98 | self.assertEqual(response.status_code, 201) 99 | response = self.client.post('/limiteddoc/', 100 | data='{"a": "hi", "b": "ho"}', 101 | content_type='application/json') 102 | self.assertEqual(response.status_code, POST_VALIDATION_ERROR_CODE) 103 | json_data = response.get_json() 104 | self.assertDictEqual(json_data[config.ISSUES], {'b': "value 'ho' is not unique"}) 105 | 106 | 107 | def test_post_invalid_schema_min_max(self): 108 | response = self.client.post('/limiteddoc/', 109 | data='{"a": "xoxo", "b": "xaxa", "f": 3}', 110 | content_type='application/json') 111 | self.assertEqual(response.status_code, POST_VALIDATION_ERROR_CODE) 112 | json_data = response.get_json() 113 | self.assertDictEqual(json_data[config.ISSUES], {'f': "min value is 5"}) 114 | 115 | response = self.client.post('/limiteddoc/', 116 | data='{"a": "xuxu", "b": "xixi", "f": 15}', 117 | content_type='application/json') 118 | self.assertEqual(response.status_code, POST_VALIDATION_ERROR_CODE) 119 | json_data = response.get_json() 120 | self.assertDictEqual(json_data[config.ISSUES], {'f': "max value is 10"}) 121 | 122 | 123 | def test_bulk_insert(self): 124 | response = self.client.post('/simpledoc/', 125 | data='[{"a": "jimmy", "b": 23}, {"a": "stefanie", "b": 47}]', 126 | content_type='application/json') 127 | 128 | for result in response.get_json()[config.ITEMS]: 129 | self.assertEqual(result[config.STATUS], "OK") 130 | 131 | 132 | def test_bulk_insert_error(self): 133 | response = self.client.post('/simpledoc/', 134 | data='[{"a": "jimmy", "b": 23}, {"a": 111, "b": 47}]', 135 | content_type='application/json') 136 | data = response.get_json()[config.ITEMS] 137 | self.assertEqual(data[0][config.STATUS], "OK") 138 | self.assertEqual(data[1][config.STATUS], "ERR") 139 | 140 | def test_post_subresource(self): 141 | s = SimpleDoc(a="Answer to everything", b=42).save() 142 | data = {'l': ['x', 'y', 'z'], 'r': str(s.id)} 143 | post_url = '/simpledoc/%s/complexdoc' % s.id 144 | response = self.client.post(post_url, data=json.dumps(data), content_type='application/json') 145 | self.assertEqual(response.status_code, 201) 146 | resp_json = response.get_json() 147 | self.assertEqual(resp_json[config.STATUS], "OK") 148 | 149 | # verify saved data 150 | response = self.client.get('/simpledoc/%s/complexdoc' % s.id) 151 | self.assertEqual(response.status_code, 200) 152 | resp_json = response.get_json() 153 | self.assertEqual(len(resp_json[config.ITEMS]), 1) 154 | self.assertEqual(resp_json[config.ITEMS][0]['l'], ['x', 'y', 'z']) 155 | 156 | def test_post_with_pre_save_hook(self): 157 | # resulting etag has to match (etag must be computed from 158 | # modified data, not from original!) 159 | data = {'a': 'hey'} 160 | response = self.client.post('/hawkeydoc/', data='{"a": "hey"}', 161 | content_type='application/json') 162 | self.assertEqual(response.status_code, 201) 163 | resp_json = response.get_json() 164 | self.assertEqual(resp_json[config.STATUS], "OK") 165 | etag = resp_json[config.ETAG] 166 | 167 | # verify etag 168 | resp = self.client.get('/hawkeydoc/%s' % resp_json['_id']) 169 | self.assertEqual(etag, resp.get_json()[config.ETAG]) 170 | -------------------------------------------------------------------------------- /tests/test_put.py: -------------------------------------------------------------------------------- 1 | 2 | import json 3 | import unittest 4 | from eve.utils import config 5 | from tests import BaseTest, SimpleDoc, ComplexDoc 6 | 7 | class TestHttpPut(BaseTest, unittest.TestCase): 8 | def setUp(self): 9 | response = self.client.post('/simpledoc/', 10 | data='{"a": "jimmy", "b": 23}', 11 | content_type='application/json') 12 | json_data = response.get_json() 13 | self.url = '/simpledoc/%s' % json_data[config.ID_FIELD] 14 | response = self.client.get(self.url).get_json() 15 | self.etag = response[config.ETAG] 16 | self._id = response[config.ID_FIELD] 17 | self.updated = response[config.LAST_UPDATED] 18 | 19 | def tearDown(self): 20 | SimpleDoc.objects().delete() 21 | 22 | def do_put(self, url=None, data=None, headers=None): 23 | if url is None: 24 | url = self.url 25 | if headers is None: 26 | headers=[('If-Match', self.etag)] 27 | return self.client.put(url, data=data, 28 | content_type='application/json', 29 | headers=headers) 30 | 31 | def test_unknown_id(self): 32 | response = self.do_put('/simpledoc/unknown', data='{"a": "greg"}') 33 | self.assertEqual(response.status_code, 404) 34 | 35 | def test_bad_etag(self): 36 | response = self.do_put(data='{"a": "greg"}', headers=(('If-Match', 'blabla'),)) 37 | self.assertEqual(response.status_code, 412) 38 | 39 | def test_ifmatch_missing(self): 40 | response = self.do_put(data='{"a": "greg"}', headers=()) 41 | self.assertEqual(response.status_code, 403) 42 | 43 | def test_put_overwrite_all(self): 44 | response = self.do_put(data='{"a": "greg", "b": 300}') 45 | 46 | response = self.client.get(self.url).get_json() 47 | self.assertIn('a', response) 48 | self.assertEqual(response['a'], "greg") 49 | self.assertIn('b', response) 50 | self.assertEqual(response['b'], 300) 51 | 52 | def test_put_overwrite_subset(self): 53 | self.do_put(data='{"a": "greg"}') 54 | response = self.client.get(self.url).get_json() 55 | self.assertIn('a', response) 56 | self.assertEqual(response['a'], "greg") 57 | self.assertNotIn('b', response) 58 | 59 | def test_put_subresource(self): 60 | # create new resource and subresource 61 | s = SimpleDoc(a="Answer to everything", b=42).save() 62 | d = ComplexDoc(l=['a', 'b'], n=999, r=s).save() 63 | 64 | response = self.client.get('/simpledoc/%s/complexdoc/%s' % (s.id, d.id)) 65 | etag = response.get_json()[config.ETAG] 66 | headers = [('If-Match', etag)] 67 | 68 | # new putted document 69 | put_data = {'l': ['x', 'y', 'z'], 'r': str(s.id)} 70 | put_url = '/simpledoc/%s/complexdoc/%s' % (s.id, d.id) 71 | response = self.client.put(put_url, data=json.dumps(put_data), 72 | content_type='application/json', headers=headers) 73 | self.assertEqual(response.status_code, 200) 74 | resp_json = response.get_json() 75 | self.assertEqual(resp_json[config.STATUS], "OK") 76 | 77 | # check, if really edited 78 | response = self.client.get('/simpledoc/%s/complexdoc/%s' % (s.id, d.id)) 79 | json_data = response.get_json() 80 | self.assertListEqual(json_data['l'], ['x', 'y', 'z']) 81 | self.assertNotIn('n', json_data) 82 | 83 | s.delete() 84 | d.delete() 85 | -------------------------------------------------------------------------------- /tests/test_queryset.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | from mongoengine import Document, StringField, queryset_manager 4 | 5 | from eve_mongoengine import EveMongoengine 6 | from tests import Eve, SETTINGS 7 | 8 | 9 | class TwoFaceDoc(Document): 10 | s = StringField() 11 | 12 | @queryset_manager 13 | def all_objects(self, queryset): 14 | return queryset.filter(s__nin=['a', 'b']) 15 | 16 | 17 | class TestMongoengineFix(unittest.TestCase): 18 | """ 19 | Test if non-standard querysets defined in datalayer work as expected. 20 | """ 21 | 22 | @classmethod 23 | def setUpClass(cls): 24 | app = Eve(settings=SETTINGS) 25 | app.debug = True 26 | ext = EveMongoengine(app) 27 | ext.add_model(TwoFaceDoc) 28 | cls.app = app 29 | cls.client = app.test_client() 30 | 31 | def test_switch_queryset(self): 32 | t1 = TwoFaceDoc(s='x').save() 33 | t2 = TwoFaceDoc(s='a').save() 34 | t3 = TwoFaceDoc(s='b').save() 35 | t4 = TwoFaceDoc(s='abc').save() 36 | 37 | # set queryset to `all_objects` 38 | self.app.data.default_queryset = 'all_objects' 39 | r = self.client.get('/twofacedoc/').get_json() 40 | returned = set([x['s'] for x in r['_items']]) 41 | self.assertSetEqual(set(['x', 'abc']), returned) 42 | 43 | # back to `objects` 44 | self.app.data.default_queryset = 'objects' 45 | r = self.client.get('/twofacedoc/').get_json() 46 | returned = set([x['s'] for x in r['_items']]) 47 | self.assertSetEqual(set(['x', 'abc', 'a', 'b']), returned) -------------------------------------------------------------------------------- /tests/test_struct.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | from copy import deepcopy 4 | 5 | from eve import Eve 6 | from mongoengine import Document, StringField, ListField, IntField 7 | 8 | from eve_mongoengine.struct import Settings 9 | from eve_mongoengine import EveMongoengine 10 | 11 | 12 | class TestSettingsDict(unittest.TestCase): 13 | def setUp(self): 14 | self.d = Settings({ 15 | 'a': 1, 16 | 'b': "hello", 17 | 'c': [1,2,3], 18 | 'd': {'x': 'y', 'y': 5}, 19 | 'e': {'q': [1,2,{'e':5}], 'w': {'r':'s', 't':'u'}} 20 | }) 21 | self.g = dict(deepcopy(self.d)) 22 | 23 | def check(self): 24 | self.assertDictEqual(self.d, self.g) 25 | 26 | def test_simple(self): 27 | self.d.update({'a': 2}) 28 | self.g['a'] = 2 29 | self.check() 30 | self.d.update({'b': "cheers"}) 31 | self.g['b'] = "cheers" 32 | self.check() 33 | 34 | def test_list(self): 35 | self.d.update({'c': [1,2,4]}) 36 | self.g['c'] = [1,2,4] 37 | self.check() 38 | self.d.update({'c': 4}) 39 | self.g['c'] = 4 40 | self.check() 41 | self.d.update({'e': {'q': [1,2]}}) 42 | self.g['e']['q'] = [1,2] 43 | self.check() 44 | 45 | def test_dict(self): 46 | self.d.update({'d': {'x': 'z'}}) 47 | self.g['d'] = {'x': 'z', 'y': 5} 48 | self.check() 49 | 50 | def test_nested_dict(self): 51 | self.d.update({'e': {'w': {'r': 5}}}) 52 | self.g['e']['w']['r'] = 5 53 | self.check() 54 | 55 | 56 | if __name__ == "__main__": 57 | unittest.main() 58 | --------------------------------------------------------------------------------