diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..9a2f3b6f2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,32 @@ +**This issue tracker is a tool to address bugs in Eve itself. +Please use Stack Overflow for general questions about using Eve or issues not +related to Eve (see http://python-eve.org/support).** + +If you'd like to report a bug in Eve, fill out the template below. Provide +any any extra information that may be useful / related to your problem. +Ideally, create an [MCVE](http://stackoverflow.com/help/mcve), which helps us +understand the problem and helps check that it is not caused by something in +your code. + +--- + +### Expected Behavior + +Tell us what should happen. + +```python +Paste a minimal example that causes the problem. +``` + +### Actual Behavior + +Tell us what happens instead. + +```pytb +Paste the full traceback if there was an exception. +``` + +### Environment + +* Python version: +* Eve version: diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 000000000..7806c1c00 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,2 @@ +daysUntilStale: 180 +staleLabel: stale diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..9592736d7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: [push, pull_request, workflow_dispatch] + +jobs: + tests: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + + strategy: + matrix: + include: + - { name: '3.12', python: '3.12', os: ubuntu-latest, tox: py312, mongodb: '5.0', redis: '6' } + - { name: '3.11', python: '3.11', os: ubuntu-latest, tox: py311, mongodb: '5.0', redis: '6' } + - { name: '3.10', python: '3.10', os: ubuntu-latest, tox: py310, mongodb: '5.0', redis: '6' } + - { name: '3.9', python: '3.9', os: ubuntu-latest, tox: py39, mongodb: '5.0', redis: '6' } + - { name: 'PyPy', python: 'pypy-3.10', os: ubuntu-latest, tox: pypy310, mongodb: '4.4', redis: '6' } + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - uses: supercharge/mongodb-github-action@1.12.0 + with: + mongodb-version: ${{ matrix.mongodb }} + - uses: supercharge/redis-github-action@1.2.0 + with: + redis-version: ${{ matrix.redis }} + - name: Install dependencies + run: | + set -xe + python -VV + python -m site + python -m pip install --upgrade pip setuptools wheel + python -m pip install --upgrade virtualenv tox tox-gh-actions + - name: 🍃 Install mongosh + run: | + sudo apt-get update + sudo apt-get install -y wget gnupg + wget -qO - https://www.mongodb.org/static/pgp/server-6.0.asc | sudo apt-key add - + echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/6.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-6.0.list + sudo apt-get update + sudo apt-get install -y mongodb-mongosh + - name: Start mongo ${{ matrix.mongodb }} + run: | + mongosh eve_test --eval 'db.createUser({user:"test_user", pwd:"test_pw", roles:["readWrite"]});' + - name: Run tox targets for ${{ matrix.python }} + run: tox -e ${{ matrix.tox }} diff --git a/.gitignore b/.gitignore index 9f55cefe9..070b35f42 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,9 @@ Include Lib Scripts +#pyenv +.python-version + #OSX .Python .DS_Store @@ -60,3 +63,11 @@ _build # PyCharm .idea + +.cache +.vscode +.pytest_cache +pip-wheel-metadata/ +!/.eggs/ +.venv/ +.claude/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..3efa5ba63 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,6 @@ +repos: + - repo: https://github.com/psf/black + rev: 23.3.0 + hooks: + - id: black + language_version: python3.9 diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 000000000..510cd6c45 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,16 @@ +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +python: + install: + - requirements: docs/requirements.txt diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 849f19801..000000000 --- a/.travis.yml +++ /dev/null @@ -1,32 +0,0 @@ -language: python -install: pip install tox -matrix: - include: - - python: 2.7 - env: TOX_ENV=py26 - - python: 2.7 - env: TOX_ENV=py27 - - python: 2.7 - env: TOX_ENV=py33 - - python: 2.7 - env: TOX_ENV=py34 - - python: 2.7 - env: TOX_ENV=pypy - - python: 2.7 - env: TOX_ENV=flake8 - - python: 3.5 - env: TOX_ENV=py35 -script: - - tox -e $TOX_ENV -services: - - mongodb - - redis-server -before_script: - # timer is needed in order to get mongo to properly initialize on travis-ci - # See https://github.com/travis-ci/travis-ci/issues/1967#issuecomment-42008605 - - sleep 15 - - mongo eve_test --eval 'db.addUser("test_user", "test_pw");' -branches: - only: - - master - - develop diff --git a/AUTHORS b/AUTHORS index 461784f23..b81522b56 100644 --- a/AUTHORS +++ b/AUTHORS @@ -8,27 +8,47 @@ Development Lead Patches and Contributions ````````````````````````` + +- Aayush Sarva +- Adam Walsh +- Adrian Cin +- Alberto Marin +- Alex Misk +- Alexander Dietmüller - Alexander Hendorf +- Alexander Miskaryan +- Alexander Urieles +- Amedeo Bussi - Andreas Røssland - Andrés Martano - Antonio Lourenco - Arnau Orriols +- Artem Kolesnikov - Arthur Burkart - Ashley Roach - Ben Demaree - Bjorn Andersson +- Brad P. Crochet +- Bret Curtis - Brian Mego - Bryan Cattle +- Calvin Smith +- Carl George - Carles Bruguera +- Chen Rotem - Christian Henke - Christoph Witzany - Christopher Larsen +- Chuck Turco +- Conrad Burchert - Cyprien Pannier - Cyril Bonnard +- DHuan - Daniel Lytkin - Daniele Pizzolli - Danse - David Arnold +- David Booss - David Buchmann - David Murphy - David Wood @@ -36,29 +56,45 @@ Patches and Contributions - Dominik Kellner - Dong Wei Ming - Dougal Matthews +- Einar Huseby +- Elias García +- Emanuele Di Giacomo - Emmanuel Leblond - Eugene Prikazchikov +- Ewan Higgs +- Felix Peppert - Florian Rathgeber +- Fouad Chennou - Francisco Corrales Morales - Garrin Kimmell - George Lestaris - Gianfranco Palumbo - Gino Zhang +- Giorgos Margaritis - Gonéri Le Bouder - Grisha K. +- Guillaume Le Pape - Guillaume Royer - Gustavo Vargas - Hamdy - Hannes Tiede - Harro van der Klauw +- Hasan Pekdemir - Henrique Barroso +- Huan Di +- Hugo Larcher +- Hung Le - James Stewart - Jaroslav Semančík - Javier Gonel +- Javier Jiménez - Jean Boussier +- Jeff Zhang - Jen Montes +- Jeremy Solbrig - Joakim Uddholm - Johan Bloemberg +- John Chang - John Deng - Jorge Morales - Jorge Puente Sarrín @@ -79,12 +115,21 @@ Patches and Contributions - Kurt Bonne - Kurt Doherty - Luca Di Gaspero +- Luca Moretto +- Luis Fernando Gomes - Magdas Adrian +- Mamurjon Saitbaev - Mandar Vaze - Manquer - Marc Abramowitz +- Marcelo Trylesinski +- Marcin Puhacz - Marcus Cobden - Marica Odagaki +- Mario Kralj +- Mark Mayo +- Marsch Huynh +- Martin Fous - Massimo Scamarcia - Mateusz Łoskot - Matt Creenan @@ -93,27 +138,45 @@ Patches and Contributions - Matthieu Prat - Mattias Lundberg - Mayur Dhamanwala +- Michael Maxwell - Mikael Berg +- Miroslav Šedivý +- Moritz Schneider - Mugur Rus - Nathan Reynolds - Niall Donegan - Nick Park - Nicolas Bazire - Nicolas Carlier +- Oleg Pshenichniy - Olivier Carrère - Olivier Poitrey +- Olof Johansson - Ondrej Slinták - Or Neeman +- Orange Tsai +- Pablo Parada +- Pahaz Blinov +- Patricia Ramos - Patrick Decat - Pau Freixes - Paul Doucet +- Pedro Rodrigues - Peter Darrow - Petr Jašek +- Phone Myint Kyaw +- Pieter De Clercq +- Prajjwal Nijhara - Prayag Verma +- Qiang Zhang +- Raghuram Devarakonda +- Rahul Salgare - Ralph Smith +- Raychee - Robert Wlodarczyk - Roberto 'Kalamun' Pasini - Rodrigo Rodriguez +- Roller Angel - Roman Gavrilov - Ronan Delacroix - Roy Smith @@ -121,21 +184,41 @@ Patches and Contributions - Sam Luu - Samuel Sutch - Samuli Tuomola +- Saurabh Shandilya - Sebastien Estienne - Sebastián Magrí +- Serge Kir +- Shaoyu Meng - Simon Schönfeld +- Sobolev Nikita - Stanislav Filin - Stanislav Heller +- Stefaan Ghysels - Stratos Gerakakis +- Svante Bengtson +- Sybren A. Stüvel +- Tadej Magajn +- Tano Abeleyra - Taylor Brown - Thomas Sileo +- Tim Gates - Tim Jacobi - Tomasz Jezierski +- Tyler Kennedy - Valerie Coffman +- Vasilis Lolis +- Vincent Bisserie - Wael M. Nasreddine +- Wan Bachtiar - Wei Guan +- Wytamma Wirth - Xavi Cubillas - boosh - dccrazyboy +- kinuax +- kreynen - mmizotin +- quentinpraz +- smeng9 +- tgm - xgdgsc diff --git a/CHANGES b/CHANGES.rst similarity index 63% rename from CHANGES rename to CHANGES.rst index ec66002c0..924958f11 100644 --- a/CHANGES +++ b/CHANGES.rst @@ -6,9 +6,962 @@ Here you can see the full list of changes between each Eve release. In Development -------------- +- *hic sunt leones* + +Version v2.3.1 +-------------- + +Released on March 24, 2026. + +- Docs: added security warnings about blind field enumeration via query + operators to ``ALLOWED_FILTERS``, ``MONGO_QUERY_BLACKLIST``, and the Filtering section. +- Fix: validate JSONP callback parameter to prevent XSS injection. +- Deprecation: ``JSONP_ARGUMENT`` is deprecated. Use CORS instead. + +Version v2.3.0 +-------------- + +Released on March 19, 2026. + +- new: ``optimize_pagination_for_speed``, a resource-level setting that allows granular control overriding the global configuration (`#1569`_) + +.. _`#1569`: https://github.com/pyeve/eve/pull/1569 + +Version v2.2.5 +-------------- + +Released on February 11, 2026. + +- fix: ``OPTIMIZE_PAGINATION_FOR_SPEED`` 'next' link incorrectly included a document id in the href when paginating through collections (`#1567`_) + +.. _`#1567`: https://github.com/pyeve/eve/pull/1567 + +Version v2.2.4 +-------------- + +Released on December 2, 2025. + +- fix: Validation issue when a field is ``id_field`` with ``unique=True`` (`#1559`_) + +.. _`#1559`: https://github.com/pyeve/eve/issues/1559 + +Version v2.2.2 and v2.2.3 +------------------------- + +Released on August 26, 2025. *Just website updates, no code changes.* + +- Drop obsolete reference links from the docs. +- Drop the twitter reference from the docs, as the author left that platform. +- `Invoicetronic`_ is now the main sponsor for this project. + +.. _Invoicetronic: https://invoicetronic.com/en/ + +Version v2.2.1 +-------------- + +Released on June 3, 2025. + +- fix: ``field_definition`` impacts negatively the performance of ``build_response_document`` (`#1552`_) +- fix: correct ``on_deleted_resource`` call signature (docs) (`#1547`_) +- fix: allow for type-checking of Eve's dynamic attrs (`#1541`_) +- CI: run tests on ubuntu-latest, as ubuntu-20.04 is being decommissioned by GitHub Actions. + +.. _`#1552`: https://github.com/pyeve/eve/issues/1552 +.. _`#1547`: https://github.com/pyeve/eve/pull/1547 +.. _`#1541`: https://github.com/pyeve/eve/pull/1541 + +Version v2.2 +------------ + +Released on Oct 15, 2024. + +- Fix: deprecation warnings in Python 3.12 (`#1526`_) +- Fix: the distribution package should not include the test suite (`#1506`_) +- Python 3.12 support. +- Python 3.11 support. +- Python 3.9 dropped. +- Python 3.8 dropped. +- .readthedocs.yml upgraded to V2. + +.. _`#1526`: https://github.com/pyeve/eve/issues/1526 +.. _`#1506`: https://github.com/pyeve/eve/issues/1506 + + +Version v2.1.0 +-------------- + +Released on Mar 14, 2023. + +New +~~~ + +- Ability to customize the pagination limit on a per-resource basis (`#1498`_) + +Fixed +~~~~~ + +- Flask 2.2+ support (`#1497`_) +- CI test runs fail with ``mongo: command not found`` on Ubuntu 22.04 (`#1499`_) + +.. _`#1499`: https://github.com/pyeve/eve/issues/1499 +.. _`#1498`: https://github.com/pyeve/eve/issues/1498 +.. _`#1497`: https://github.com/pyeve/eve/issues/1497 + +Version v2.0.4 +-------------- + +Released on Nov 10, 2022. + +Fixed +~~~~~ + +- Comparison of incompatible types (`#1492`_) +- Python 3 updates, and some refactoring (`#1493`_) + +.. _`#1492`: https://github.com/pyeve/eve/issues/1492 +.. _`#1493`: https://github.com/pyeve/eve/pull/1493 + +Version v2.0.3 +-------------- + +Released on Nov 2, 2022. + +Fixed +~~~~~ + +- Malformed ``LAST_UPDATED`` field (`#1490`_) + +.. _`#1490`: https://github.com/pyeve/eve/issues/1490 + +Version v2.0.2 +-------------- + +Released on Sep 23, 2022. + +Fixed +~~~~~ + +- Fix: etag generation fails if ``uuidRepresentation`` is not set in MONGO_OPTIONS (`#1486`_) + +.. _`#1486`: https://github.com/pyeve/eve/issues/1486 + +Version v2.0.1 +-------------- + +Released on Sep 7, 2022. + +Fixed +~~~~~ + +- ``MONGO_URI`` username, password, and authSource are not parsed correctly (`#1478`_) +- Lock Flask dependency to version 2.1 (`#1485`_) +- Fix documentation typos (`#1481`_) +- Only build Python 3 wheels. + +.. _`#1485`: https://github.com/pyeve/eve/issues/1485 +.. _`#1478`: https://github.com/pyeve/eve/issues/1478 +.. _`#1481`: https://github.com/pyeve/eve/pull/1481 + +Version v2.0 +------------ + +Released on Jun 8, 2022. + +Breaking +~~~~~~~~ +Starting from this release, Eve supports Python 3.7 and above. + +- Drop Python 2 (`#1440`_) +- Drop Python 3.5 (`#1440`_, `#1438`_) +- Drop Python 3.6 (`#1440`_) + +.. _`#1440`: https://github.com/pyeve/eve/issues/1440 +.. _`#1438`: https://github.com/pyeve/eve/pull/1438 + +New +~~~ + +- Add Python 3.9 support (`#1437`_) +- Add Python 3.10 support (`#1440`_) +- ``MONGO_OPTIONS`` acquires a new ``uuidRepresentation`` setting, with ``standard`` as its default value. This is needed by PyMongo 4+ in order to seamlessly process eventual ``uuid`` values. See `PyMongo documentation`_ for details (`#1461`_, `#1464`_). + + +.. _`#1444`: https://github.com/pyeve/eve/pull/1444 +.. _`PyMongo documentation`: https://github.com/pyeve/eve/pull/1438 + +Fixed +~~~~~ + +- AttributeError: module 'werkzeug.utils' has no attribute 'escape' (`#1474`_) +- Starting with Werkzeug 2.1, HATEOAS links are relative instead of absolute (`#1475`_) +- Eve doesn't work with latest PyMongo (v4) (`#1461`_, `#1464`_) +- Fix 500 error with empty token/bearer (`#1456`_) +- Do not return related fields if field is a empty list (`#1441`_) +- PyMongo 3.12+ supports keys that include dotted fields (`#1466`_) +- Pin pymongo version in dependencies (`#1461`_) +- Prepare for Python 3 switch (`#1445`_) +- Update docs and tests regarding pagination of empty resources (`#1463`_) +- Fix fork link in contributing info (`#1447`_) +- Tutorial mistake on custom IDs values with UUIDs (`#1451`_) +- Documentation typos (`#1462`_, `#1469`_) +- Switch to GitHub Actions from Travis CI (`#1439`_, `#1444`_) + +.. _`#1469`: https://github.com/pyeve/eve/pull/1469 +.. _`#1475`: https://github.com/pyeve/eve/issues/1475 +.. _`#1474`: https://github.com/pyeve/eve/issues/1474 +.. _`#1451`: https://github.com/pyeve/eve/issues/1451 +.. _`#1464`: https://github.com/pyeve/eve/issues/1464 +.. _`#1461`: https://github.com/pyeve/eve/issues/1461 +.. _`#1439`: https://github.com/pyeve/eve/pull/1439 +.. _`#1437`: https://github.com/pyeve/eve/pull/1437 +.. _`#1456`: https://github.com/pyeve/eve/pull/1456 +.. _`#1463`: https://github.com/pyeve/eve/pull/1463 +.. _`#1462`: https://github.com/pyeve/eve/pull/1462 +.. _`#1466`: https://github.com/pyeve/eve/issues/1466 +.. _`#1447`: https://github.com/pyeve/eve/pull/1447 +.. _`#1445`: https://github.com/pyeve/eve/pull/1445 +.. _`#1441`: https://github.com/pyeve/eve/pull/1441 + +Version 1.1.5 +------------- + +Released on January 25, 2021. + +Fixed +~~~~~ + +- Nested unique field validation still don't work (`#1435`_) +- Documentation: corrected variable name (`#1426`_) +- Versioning: support for dynamic datasources (`#1423`_) +- Disable MD5 support in GridFS, as it is deprecated (`#1410`_) +- Demo application has been terminated by Heroku. Dropped any reference to it. + +.. _`#1435`: https://github.com/pyeve/eve/issues/1435 +.. _`#1426`: https://github.com/pyeve/eve/pull/1426 +.. _`#1423`: https://github.com/pyeve/eve/issues/1423 +.. _`#1410`: https://github.com/pyeve/eve/issues/1410 + +Version 1.1.4 +------------- + +Released on October 22, 2020. + +Fixed +~~~~~ + +- Error raised when using ``embedded`` with nested dict (`#1416`_) +- Expose media endpoint only if ``RETURN_MEDIA_AS_URL`` is set to ``True`` (`#1415`_) +- Use ``**mongo_options`` in ``with_options`` (`#1413`_) + +.. _`#1416`: https://github.com/pyeve/eve/issues/1416 +.. _`#1415`: https://github.com/pyeve/eve/pull/1415 +.. _`#1413`: https://github.com/pyeve/eve/issues/1413 + +Version 1.1.3 +------------- + +Released on September 19, 2020. + +Fixed +~~~~~ + +- Fix: Race condition in PATCH on newly created documents with clustered mongo (`#1411`_) + +.. _`#1411`: https://github.com/pyeve/eve/issues/1411 + +Version 1.1.2 +------------- + +Released on July 9, 2020. + +Fixed +~~~~~ + +- Add missed condition when projection is disabled per domain (`#1398`_) +- Removed unnecessary comprehension (`#1391`_) + +.. _`#1398`: https://github.com/pyeve/eve/pull/1398 +.. _`#1391`: https://github.com/pyeve/eve/pull/1391 + +Version 1.1.1 +------------- + +Released on May 10, 2020. + +Fixed +~~~~~ + +- Disabling ``merge_nested_documents`` breaks versioning on PATCH (`#1389`_) +- Tests failing with Flask 1.1.2 (`#1378`_) +- ``BANDWIDTH_SAVER`` no longer works with ``resolve_resource_projection`` (`#1338`_) +- ``unique_within_resource`` rule used in resources without datasource filter (`#1368`_) +- dicts without ``schema`` rule are broken since ``b8d8fcd`` (`#1366`_) +- 403 Forrbidden added to ``STANDARD_ERRORS`` (`#1362`_) +- ``unique`` constraint doesn't work when inside of a dict or a list (`#1360`_) +- Documentation typos (`#1375`_) + +.. _`#1389`: https://github.com/pyeve/eve/issues/1389 +.. _`#1378`: https://github.com/pyeve/eve/pull/1378 +.. _`#1375`: https://github.com/pyeve/eve/pull/1375 +.. _`#1338`: https://github.com/pyeve/eve/issues/1338 +.. _`#1368`: https://github.com/pyeve/eve/pull/1368 +.. _`#1366`: https://github.com/pyeve/eve/pull/1366 +.. _`#1362`: https://github.com/pyeve/eve/pull/1362 +.. _`#1360`: https://github.com/pyeve/eve/issues/1360 + +Version 1.1 +----------- + +Released on February 7, 2020. + +New +~~~ +- ``MONGO_QUERY_WHITELIST`` and ``mongo_query_whitelist``. A list of extra Mongo + query operators to allow besides the official list of allowed operators. + Defaults to ``[]``. (`#1351`_) + +Fixed +~~~~~ +- Starup crash with Werkzeug 1.0 (`#1359`_) +- ``$eq`` is missing from supported query operators (`#1351`_) +- Documentation typos (`#1348`_, `#1350`_) + +.. _`#1359`: https://github.com/pyeve/eve/issues/1359 +.. _`#1351`: https://github.com/pyeve/eve/issues/1351 +.. _`#1350`: https://github.com/pyeve/eve/pull/1350 +.. _`#1348`: https://github.com/pyeve/eve/issues/1348 + +Version 1.0.1 +------------- + +Released on January 26, 2020. + +- Fix: Mixing foreign and local object ids breaks querying (`#1345`_) + +.. _`#1345`: https://github.com/pyeve/eve/issues/1345 + +Version 1.0 +----------- + +Released on December 19, 2019. + +New +~~~ +- Python 3.8 added to CI matrix (`#1326`_) +- Drop support for Python 3.4 (`#1297`_) +- ``unique_within_resource`` validation rule. Enforces the uniqueness of an + attribute only at API resource level, contrasting with the ``unique`` rule + that enforces uniqueness at database collection level (`#1291`_) +- Add doc8 to dev-requirements (`#1343`_) + +.. _`#1343`: https://github.com/pyeve/eve/issues/1343 +.. _`#1326`: https://github.com/pyeve/eve/issues/1326 +.. _`#1297`: https://github.com/pyeve/eve/issues/1297 +.. _`#1291`: https://github.com/pyeve/eve/issues/1291 + +Fixed +~~~~~ +- Pin to Cerberus < 2.0 (`#1342`_) +- 500 error when PATCH or PUT are performed on Mongo 4.2 and ``_id`` is + included with payload (`#1341`_) +- Minor style improvements and 2 test fixes (`#1330`_) +- Werkzeug 0.15.4 crashes with Python 3.8 (`#1325`_) +- Curl request in projection examples do not work (`#1298`_) +- Update installation instructions (`#1303`_) +- (*breaking*) Delete on empty resource returns 404, should return 204 + (`#1299`_) +- ``MONGO_REPLICA_SET`` ignored (`#1302`_) +- Documentation typo (`#1293`_, `#1315`_, `#1322`_, `#1324`_, `#1327`_) +- Flask 1.1.1 breaks ``test_logging_info`` test (`#1296`_) +- Display the full release number on Eve frontpage. +- Update link to EveGenie repository. New maintainer: David Zisky. + +.. _`#1342`: https://github.com/pyeve/eve/issues/1342 +.. _`#1341`: https://github.com/pyeve/eve/issues/1341 +.. _`#1330`: https://github.com/pyeve/eve/pull/1330 +.. _`#1327`: https://github.com/pyeve/eve/pull/1327 +.. _`#1325`: https://github.com/pyeve/eve/pull/1325 +.. _`#1324`: https://github.com/pyeve/eve/pull/1324 +.. _`#1322`: https://github.com/pyeve/eve/pull/1322 +.. _`#1315`: https://github.com/pyeve/eve/pull/1315 +.. _`#1298`: https://github.com/pyeve/eve/issues/1298 +.. _`#1303`: https://github.com/pyeve/eve/pull/1303 +.. _`#1299`: https://github.com/pyeve/eve/issues/1299 +.. _`#1302`: https://github.com/pyeve/eve/issues/1302 +.. _`#1296`: https://github.com/pyeve/eve/issues/1296 +.. _`#1293`: https://github.com/pyeve/eve/issues/1293 + +Version 0.9.2 +------------- + +Released on June 14, 2019. + +Fixed +~~~~~ + + +- Geo queries lack support for the ``$minDistance`` mongo operator (`#1281`_) +- Lookup argument does not get passed to ``pre_`` hook with certain + resource urls (`#1283`_) +- PUT requests doesn't set default values for fields that have one defined + (`#1280`_) +- PATCH crashes when normalizing default fields (`#1275`_, `#1274`_) +- The condition that avoids returning ``X-Total-Count`` when counting is + disabled also filters out the case where the resource is empty and count is + 0 (`#1279`_) +- First example of Eve use doesn't really work (`#1277`_) + +.. _`#1283`: https://github.com/pyeve/eve/issues/1283 +.. _`#1281`: https://github.com/pyeve/eve/issues/1281 +.. _`#1280`: https://github.com/pyeve/eve/issues/1280 +.. _`#1277`: https://github.com/pyeve/eve/issues/1277 +.. _`#1275`: https://github.com/pyeve/eve/issues/1275 +.. _`#1274`: https://github.com/pyeve/eve/issues/1274 +.. _`#1279`: https://github.com/pyeve/eve/issues/1279 + +Version 0.9.1 +------------- + +Released on May 22, 2019. + +New +~~~~~ +- ``NORMALIZE_ON_PATCH`` switches normalization on patch requests (`#1234`_) + +Fixed +~~~~~ +- Document count broken with concurrent requests (`#1271`_) +- Document count broken when embedded resources are requested (`#1268`_) +- If ``ignore_fields`` contains a nested field, document is mutated (`#1266`_) +- Crash with Werzeug >= 0.15.3 (`#1267`_) +- Fix crash when trying to ignore a nested field that doesn't exist (`#1263`_) + +Improved +~~~~~~~~ +- Remove unsupported ``transparent_schema_rules`` option from docs (`#1264`_) +- Bump (and pin) Wekzeug to 0.15.4 (`#1267`_) +- Quickstart: a better ``MONGO_AUTH_SOURCE`` explanation (`#1168`_) + +Breaking Changes +~~~~~~~~~~~~~~~~ + +No known breaking changes for the standard framework user. However, if you are +consuming the developer API: + +- Be aware that ``io.base.DataLayer.find()`` signature has changed and an + optional ``perform_count`` argument has been added. The method return value + is now a tuple ``(cursor, count)``; ``cursor`` is the query result as + before while ``count`` is the document count, which is expected to have a + consistent value when ``perform_count = True``. + +.. _`#1271`: https://github.com/pyeve/eve/issues/1271 +.. _`#1268`: https://github.com/pyeve/eve/issues/1268 +.. _`#1168`: https://github.com/pyeve/eve/issues/1168 +.. _`#1266`: https://github.com/pyeve/eve/pull/1266 +.. _`#1234`: https://github.com/pyeve/eve/issues/1234 +.. _`#1267`: https://github.com/pyeve/eve/issues/1267 +.. _`#1263`: https://github.com/pyeve/eve/pull/1263 +.. _`#1264`: https://github.com/pyeve/eve/issues/1264 + +Version 0.9 +----------- + +Released on April 11, 2019. + +Breaking changes +~~~~~~~~~~~~~~~~ +- Werkzeug v0.15.1+ is required. You want to upgrade, otherwise your Eve + environment is likely to break. For the full story, see `#1245`_ and + `#1251`_. + +New +~~~ +- HATEOAS support added to aggregation results (`#1208`_) +- ``on_fetched_diffs`` event hooks (`#1224`_) +- Support for Mongo 3.6+ ``$expr`` query operator. +- Support for Mongo 3.6+ ``$center`` query operator. + +Fixed +~~~~~ +- Insertion failure when replacing unknown field with dbref value (`#1255`_, + `#1257`_) +- ``max_results=1`` should be honored on aggregation endpoints (`#1250`_) +- PATCH incorrectly normalizes default values in subdocuments (`#1234`_) +- Unauthorized Exception not working with Werkzeug >= 15.0 (`#1245`_, `#1251`_) +- Embedded documents not being sorted correctly (`#1217`_) +- Eve crashes on malformed sort parameters (`#1248`_) +- Insertion failure when replacing a same document containing dbref (`#1216`_) +- Datasource projection is not respected for POST requests (`#1189`_) +- Soft delete removes ``auth_field`` from document (`#1188`_) +- On Mongo 3.6+, we don't return 400 'immutable field' on PATCH and PUT + (`#1243`_) +- Expecting JSON response for rate limit exceeded scenario (`#1227`_) +- Multiple concurrent patches to the same record, from different processes, + should result in at least one patch failing with a 412 error (Precondition + Failed) (`#1231`_) +- Embedding only does not follow ``data_relation.field`` (`#1069`_) +- HATEOAS ``_links`` seems to get an extra ``&version=diffs`` (`#1228`_) +- Do not alter ETag when performing an oplog_push (`#1206`_) +- CORS response headers missing for media endpoint (`#1197`_) +- Warning: Unexpected keys present on black: ``python_version`` (`#1244`_) +- UserWarning: JSON setting is deprecated. Use RENDERERS instead (`#1241`_). +- DeprecationWarning: decodestring is deprecated, use decodebytes (`#1242`_) +- DeprecationWarning: count is deprecated. Use Collection.count_documents + instead (`#1202`_) +- Documentation typos (`#1218`_, `#1240`_) + +Improved +~~~~~~~~ +- Eve package is now distributed as a Python wheel (`#1260`_) +- Bump Werkzeug version to v0.15.1+ (`#1245`_, `#1251`_) +- Bump PyMongo version to v3.7+ (`#1202`_) +- Python 3.7 added to the CI matrix (`#1199`_) +- Option to omit the aggregation stage when its parameter is empty/unset + (`#1209`_) +- HATEOAS: now the ``_links`` dictionary may have a ``related`` dictionary + inside, and each key-value pair yields the related links for a data relation + field (`#1204`_) +- XML renderer now supports data field tag attributes such as ``href`` and + ``title`` (`#1204`_) +- Make the parsing of ``req.sort`` and ``req.where`` easily reusable by moving + their logic to dedicated methods (`#1194`_) +- Add a "Python 3 is highly preferred" note on the homepage (`#1198`_) +- Drop sphinx-contrib-embedly when building docs. + +.. _`#1260`: https://github.com/pyeve/eve/issues/1260 +.. _`#1208`: https://github.com/pyeve/eve/issues/1208 +.. _`#1257`: https://github.com/pyeve/eve/issues/1257 +.. _`#1255`: https://github.com/pyeve/eve/issues/1255 +.. _`#1250`: https://github.com/pyeve/eve/issues/1250 +.. _`#1234`: https://github.com/pyeve/eve/issues/1234 +.. _`#1251`: https://github.com/pyeve/eve/pull/1251 +.. _`#1245`: https://github.com/pyeve/eve/pull/1245 +.. _`#1217`: https://github.com/pyeve/eve/pull/1217 +.. _`#1248`: https://github.com/pyeve/eve/issues/1248 +.. _`#1234`: https://github.com/pyeve/eve/issues/1234 +.. _`#1216`: https://github.com/pyeve/eve/issues/1216 +.. _`#1244`: https://github.com/pyeve/eve/issues/1244 +.. _`#1189`: https://github.com/pyeve/eve/issues/1189 +.. _`#1188`: https://github.com/pyeve/eve/issues/1188 +.. _`#1198`: https://github.com/pyeve/eve/issues/1198 +.. _`#1199`: https://github.com/pyeve/eve/issues/1199 +.. _`#1243`: https://github.com/pyeve/eve/issues/1243 +.. _`#1241`: https://github.com/pyeve/eve/issues/1241 +.. _`#1242`: https://github.com/pyeve/eve/issues/1242 +.. _`#1202`: https://github.com/pyeve/eve/issues/1202 +.. _`#1240`: https://github.com/pyeve/eve/issues/1240 +.. _`#1227`: https://github.com/pyeve/eve/issues/1227 +.. _`#1231`: https://github.com/pyeve/eve/issues/1231 +.. _`#1069`: https://github.com/pyeve/eve/issues/1069 +.. _`#1224`: https://github.com/pyeve/eve/pull/1224 +.. _`#1228`: https://github.com/pyeve/eve/pull/1228 +.. _`#1218`: https://github.com/pyeve/eve/pull/1218 +.. _`#1209`: https://github.com/pyeve/eve/issues/1209 +.. _`#1206`: https://github.com/pyeve/eve/issues/1206 +.. _`#1204`: https://github.com/pyeve/eve/pull/1204 +.. _`#1194`: https://github.com/pyeve/eve/pull/1194 +.. _`#1197`: https://github.com/pyeve/eve/issues/1197 + +Version 0.8.1 +------------- + +Released on October 4, 2018. + +New +~~~ +- Add support for Mongo ``$centerSphere`` query operator (`#1181`_) +- ``NORMALIZE_DOTTED_FIELDS``. If ``True``, dotted fields are parsed and + processed as subdocument fields. If ``False``, dotted fields are left + unparsed and unprocessed and the payload is passed to the underlying + data-layer as-is. Please note that with the default Mongo layer, setting this + to ``False`` will result in an error. Defaults to ``True``. (`#1173`_) +- ``normalize_dotted_fields``. Endpoint-level override + for ``NORMALIZE_DOTTED_FIELDS``. (`#1173`_) + +Fixed +~~~~~ +- ``mongo_indexes``: "OperationFailure" when changing the keys of an existing + index (`#1180`_) +- v0.8: "OperationFailure" performing MongoDB full text searches (`#1176`_) +- "AttributeError" on Python 2.7 when obsolete ``JSON`` or ``XML`` settings + are used (`#1175`_). +- "TypeError argument of type 'NoneType' is not iterable" error when using + document embedding in conjuction with soft deletes (`#1120`_) +- ``allow_unknown`` validation rule fails with nested dict fields (`#1163`_) +- Updating a field with a nullable data relation fails when value is null + (`#1159`_) +- "cerberus.schema.SchemaError" when ``VALIDATE_FILTERS = True``. (`#1154`_) +- Serializers fails when array of types is in schema. (`#1112`_) +- Replace the broken ``make audit`` shortcut with ``make check``, add the + command to ``CONTRIBUTING.rst`` it was missing. (`#1144`_) + +Improved +~~~~~~~~ +- Perform lint checks and fixes on staged files, as a pre-commit hook. + (`#1157`_) +- On CI, perform linting checks first. If linting checks are successful, + execute the test suite on the whole matrix. (`#1156`_) +- Reformat code to match Black code-style. (`#1155`_) +- Use ``simplejson`` everywhere in the codebase. (`#1148`_) +- Install a bot that flags and closes stale issues/pull requests. (`#1145`_) +- Only set the package version in ``__init__.py``. (`#1142`_) + +Docs +~~~~ +- Typos (`#1183`_, `#1184`_, `#1185`_) +- Add ``MONGO_AUTH_SOURCE`` to Quickstart. (`#1168`_) +- Fix Sphinx-embedly error when embedding speakerdeck.com slide deck (`#1158`_) +- Fix broken link to the Postman app. (`#1150`_) +- Update obsolete PyPI link in docs sidebar. (`#1152`_) +- Only display the version number on the docs homepage. (`#1151`_) +- Fix documentation builds on Read the Docs. (`#1147`_) +- Add a ``ISSUE_TEMPLATE.md`` GitHub template file. (`#1146`_) +- Improve changelog format to reduce noise and increase readability. (`#1143`_) + +.. _`#1185`: https://github.com/pyeve/eve/pull/1185 +.. _`#1184`: https://github.com/pyeve/eve/pull/1184 +.. _`#1183`: https://github.com/pyeve/eve/pull/1183 +.. _`#1181`: https://github.com/pyeve/eve/issues/1181 +.. _`#1180`: https://github.com/pyeve/eve/issues/1180 +.. _`#1176`: https://github.com/pyeve/eve/issues/1176 +.. _`#1175`: https://github.com/pyeve/eve/issues/1175 +.. _`#1173`: https://github.com/pyeve/eve/issues/1173 +.. _`#1168`: https://github.com/pyeve/eve/issues/1168 +.. _`#1142`: https://github.com/pyeve/eve/issues/1142 +.. _`#1143`: https://github.com/pyeve/eve/issues/1143 +.. _`#1144`: https://github.com/pyeve/eve/issues/1144 +.. _`#1145`: https://github.com/pyeve/eve/issues/1145 +.. _`#1146`: https://github.com/pyeve/eve/issues/1146 +.. _`#1147`: https://github.com/pyeve/eve/issues/1147 +.. _`#1148`: https://github.com/pyeve/eve/issues/1148 +.. _`#1151`: https://github.com/pyeve/eve/issues/1151 +.. _`#1152`: https://github.com/pyeve/eve/issues/1152 +.. _`#1150`: https://github.com/pyeve/eve/issues/1150 +.. _`#1112`: https://github.com/pyeve/eve/issues/1112 +.. _`#1154`: https://github.com/pyeve/eve/issues/1154 +.. _`#1155`: https://github.com/pyeve/eve/issues/1155 +.. _`#1156`: https://github.com/pyeve/eve/issues/1156 +.. _`#1157`: https://github.com/pyeve/eve/issues/1157 +.. _`#1158`: https://github.com/pyeve/eve/issues/1158 +.. _`#1159`: https://github.com/pyeve/eve/issues/1159 +.. _`#1163`: https://github.com/pyeve/eve/issues/1163 +.. _`#1120`: https://github.com/pyeve/eve/issues/1120 + +Version 0.8 +----------- + +Released on May 10, 2018. + +.. note:: + + Make sure you read the :ref:`Breaking Changes ` section below. + +- New: support for `partial media requests`_. Clients can request partial file + downloads by adding a ``Range`` header to their media request (`#1050`_). +- New: `Renderer classes`_. ``RENDERER`` allows to change enabled renderers. + Defaults to ``['eve.render.JSONRenderer', 'eve.render.XMLRenderer']``. You + can create your own renderer by subclassing ``eve.render.Renderer``. Each + renderer should set valid mime attr and have ``.render()`` method + implemented. Please note that at least one renderer must always be enabled + (`#1092`_). +- New: ``on_delete_resource_originals`` fired when soft deletion occurs + (`#1030`_). +- New: ``before_aggregation`` and ``after_aggregation`` event hooks allow to + attach `custom callbacks to aggregation endpoints`_ (`#1057`_). +- New: ``JSON_REQUEST_CONTENT_TYPES`` or supported JSON content types. Useful + when you need support for vendor-specific json types. Please note: responses + will still carry the standard ``application/json`` type. Defaults to + ``['application/json']`` (`#1024`_). +- New: when the media endpoint is enabled, the default authentication class + will be used to secure it. (`#1083`_; `#1049`_). +- New: ``MERGE_NESTED_DOCUMENTS``. If ``True``, updates to nested fields are + merged with the current data on ``PATCH``. If ``False``, the updates + overwrite the current data. Defaults to ``True`` (`#1140`_). +- New: support for MongoDB decimal type ``bson.decimal128.Decimal128`` + (`#1045`_). +- New: Support for ``Feature`` and ``FeatureCollection`` GeoJSON objects + (`#769`_). +- New: Add support for MongoDB ``$box`` geo query operator (`#1122`_). +- New: ``ALLOW_CUSTOM_FIELDS_IN_GEOJSON`` allows custom fields in GeoJSON + (`#1004`_). +- New: Add support for MongoDB ``$caseSensitive`` and ``$diactricSensitive`` + query operators (`#1126`_). +- New: Add support for MongoDB bitwise query operators ``$bitsAllClear``, + ``$bitsAllSet``, ``$bitsAnyClear``, ``$bitsAnySet`` (`#1053`_). +- New: support for ``MONGO_AUTH_MECHANISM`` and + ``MONGO_AUTH_MECHANISM_PROPERTIES``. +- New: ``MONGO_DBNAME`` can now be used in conjuction with ``MONGO_URI``. + Previously, if ``MONGO_URI`` was missing the database name, an exception + would be rised (`#1037`_). +- Fix: OPLOG skipped even if ``OPLOG = True`` (`#1074`_). +- Fix: Cannot define default projection and request specific field. (`#1036`_). +- Fix: ``VALIDATE_FILTERS`` and ``ALLOWED_FILTERS`` do not work with + sub-document fields. (`#1123`_). +- Fix: Aggregation query parameter does not replace keys in the lists + (`#1025`_). +- Fix: serialization bug that randomly skips fields if "x_of" is encountered + (`#1042`_) +- Fix: PUT behavior with User Restricted Resource Access. Ensure that, under + every circumstance, users are unable to overwrite items owned by other users + (`#1130`_). +- Fix: Crash with Cerberus 1.2 (`#1137`_). +- Fix documentation typos (`#1114`_, `#1102`_) +- Fix: broken documentation links to Cerberus validation rules. +- Fix: add sphinxcontrib-embedly to dev-requirements.txt. +- Fix: Removed OrderedDict dependency; use ``OrderedDict`` from + ``backport_collections`` instead (`#1070`_). +- Performance improved on retrieving a list of embedded documents (`#1029`_). +- Dev: Refactor index creation. We now have a new + ``eve.io.mongo.ensure_mongo_indexes()`` function which ensures that eventual + ``mongo_indexes`` defined for a resource are created on the active database. + The function can be imported and invoked, for example in multi-db workflows + where a db is activated based on the authenticated user performing the + request (via custom auth classes). +- Dev: Add a `Makefile with shortcuts`_ for testing, docs building, and + development install. +- Dev: Switch to pytest as the standard testing tool. +- Dev: Drop ``requiments.txt`` and ``dev-requirements.txt``. Use ``pip install + -e .[dev|tests|docs]`` instead. +- Tests: finally acknowledge the existence of modern APIs for both Mongo and + Python (get rid of most deprecation warnings). +- Change: Support for Cerberus 1.0+ (`#776`_). +- Change: ``JSON`` and ``XML`` settings are deprecated and will be removed in + a future update. Use ``RENDERERS`` instead (`#1092`_). +- Flask dependency set to >=1.0 (`#1111`_). +- PyMongo dependency set to >=3.5. +- Events dependency set to >=v0.3. +- Drop Flask-PyMongo dependency, use custom code instead (`#855`_). +- Docs: Comprehensive rewrite of the `How to contribute`_ page. +- Docs: Drop the testing page; merge its contents with `How to contribute`_. +- Docs: Add link to the `Eve course`_. It was authored by the project author, + and it is hosted by TalkPython Training. +- Docs: code snippets are now Python 3 compatibile (Pahaz Blinov). +- Dev: Delete and cleanup of some unnecessary code. +- Dev: after the latest update (May 4th) travis-ci would not run tests on + Python 2.6. +- Dev: all branches are now tested on travis-ci. Previously, only 'master' was + being tested. +- Dev: fix insidious bug in ``tests.methods.post.TestPost`` class. + +.. _breaking_changes: + +Breaking Changes +~~~~~~~~~~~~~~~~ +- Python 2.6 and Python 3.3 are no longer supported (`#1129`_). +- Eve now relies on `Cerberus`_ 1.1+ (`#776`_). It allows for many new + powerful validation and trasformation features (like `schema registries`_), + improved performance and, in general, a more streamlined API. It also brings + some notable breaking changes. + + - ``keyschema`` was renamed to ``valueschema``, and ``propertyschema`` to + ``keyschema``. + - A PATCH on a document which misses a field having a default value will + now result in setting this value, even if the field was not provided in + the PATCH's payload. + - Error messages for ``keyschema`` are now returned as dictionary. Example: + ``{'a_dict': {'a_field': "value does not match regex '[a-z]+'"}}``. + - Error messages for type validations are `different now`_. + - It is no longer valid to have a field with ``default = None`` and + ``nullable = False`` (see + *patch.py:test_patch_nested_document_nullable_missing*). + - And more. A complete list of breaking changes is available here_. For + detailed upgrade instructions, see Cerberus `upgrade notes`_. An in-depth + analysis of changes made to the codebase (useful if you wrote a custom + validator which needs to be upgraded) is available with `this commit + message`_. + - Special thanks to Dominik Kellner and Brad P. Crochet for the amazing job + done on this upgrade. + +- Config setting ``MONGO_AUTHDBNAME`` renamed into ``MONGO_AUTH_SOURCE`` for + naming consistency with PyMongo. +- Config options ``MONGO_MAX_POOL_SIZE``, ``MONGO_SOCKET_TIMEOUT_MS``, + ``MONGO_CONNECT_TIMEOUT_MS``, ``MONGO_REPLICA_SET``, + ``MONGO_READ_PREFERENCE`` removed. Use ``MONGO_OPTIONS`` or ``MONGO_URI`` + instead. +- Be aware that ``DELETE`` on sub-resource endpoint will now only delete the + documents matching endpoint semantics. A delete operation on + ``people/51f63e0838345b6dcd7eabff/invoices`` will delete all documents + matching the followig query: ``{'contact_id': '51f63e0838345b6dcd7eabff'}`` + (`#1010`_). + +.. _#1140: https://github.com/pyeve/eve/pull/1140 +.. _#1111: https://github.com/pyeve/eve/issues/1111 +.. _#1129: https://github.com/pyeve/eve/issues/1129 +.. _#1057: https://github.com/pyeve/eve/issues/1057 +.. _#1137: https://github.com/pyeve/eve/issues/1137 +.. _#1122: https://github.com/pyeve/eve/issues/1122 +.. _#1050: https://github.com/pyeve/eve/pull/1050 +.. _#1130: https://github.com/pyeve/eve/pull/1130 +.. _#1074: https://github.com/pyeve/eve/issues/1074 +.. _#1036: https://github.com/pyeve/eve/issues/1036 +.. _#1128: https://github.com/pyeve/eve/pull/1128 +.. _#1126: https://github.com/pyeve/eve/pull/1126 +.. _#1123: https://github.com/pyeve/eve/issues/1123 +.. _#1102: https://github.com/pyeve/eve/pull/1102 +.. _#1114: https://github.com/pyeve/eve/pull/1114 +.. _#1092: https://github.com/pyeve/eve/pull/1092 +.. _#1083: https://github.com/pyeve/eve/issues/1083 +.. _#1049: https://github.com/pyeve/eve/issues/1049 +.. _#1053: https://github.com/pyeve/eve/issues/1053 +.. _#1070: https://github.com/pyeve/eve/pull/1070 +.. _#1045: https://github.com/pyeve/eve/issues/1045 +.. _#1042: https://github.com/pyeve/eve/pull/1042 +.. _#1030: https://github.com/pyeve/eve/pull/1030 +.. _#1037: https://github.com/pyeve/eve/issues/1037 +.. _#1029: https://github.com/pyeve/eve/issues/1029 +.. _#1024: https://github.com/pyeve/eve/issues/1024 +.. _#769: https://github.com/pyeve/eve/issues/769 +.. _#1004: https://github.com/pyeve/eve/issues/1004 +.. _#776: https://github.com/pyeve/eve/issues/776 +.. _#855: https://github.com/pyeve/eve/issues/855 +.. _#1010: https://github.com/pyeve/eve/issues/1010 +.. _#1025: https://github.com/pyeve/eve/issues/1025 +.. _Cerberus: http://python-cerberus.org +.. _`schema registries`: http://docs.python-cerberus.org/en/stable/schemas.html#registries +.. _`different now`: http://docs.python-cerberus.org/en/stable/upgrading.html#data-types +.. _here: http://docs.python-cerberus.org/en/stable/changelog.html#breaking-changes +.. _`upgrade notes`: http://python-cerberus.org/en/stable/upgrading.html +.. _`this commit message`: https://github.com/pyeve/eve/pull/1001/commits/1110f807b478efa9f13ad1d217d22ceaa2a9e42d +.. _`partial media requests`: http://python-eve.org/features.html#partial-media-downloads +.. _`custom callbacks to aggregation endpoints`: http://python-eve.org/features.html#aggregation-event-hooks +.. _`Renderer classes`: http://python-eve.org/features.html#rendering +.. _`makefile with shortcuts`: http://python-eve.org/contributing.html#make-targets +.. _`How to contribute`: http://python-eve.org/contributing.html +.. _`Eve course`: https://training.talkpython.fm/courses/explore_eve/eve-building-restful-mongodb-backed-apis-course + +Version 0.7.10 +~~~~~~~~~~~~~~ + +Released on July 15, 2018. + +- Fix: Pin Flask-PyMongo dependency to avoid crash with Flask-PyMongo 2. + Closes #1172. + +Version 0.7.9 +~~~~~~~~~~~~~ + +Released on May 10, 2018 + +- Python 2.6 and Python 3.3 are deprecated. Closes #1129. + +Version 0.7.8 +~~~~~~~~~~~~~ + +Released on 7 February, 2018 + +- Fix: breaking syntax error in v0.7.7 + +Version 0.7.7 +~~~~~~~~~~~~~ + +Released on 7 February, 2018 + +- Fix: geo queries now properly support ``$geometry`` and ``$maxDistance`` + operators. Closes #1103. + +Version 0.7.6 +~~~~~~~~~~~~~ + +Released on 14 January, 2018 + +- Improve query parsing robustness. + +Version 0.7.5 +~~~~~~~~~~~~~ + +Released on 4 December, 2017 + +- Fix: A query was not fully traversed in the sanitization. Therefore the + blacklist for mongo wueries could be bypassed, allowing for dangerous + ``$where`` queries (Moritz Schneider). + +Version 0.7.4 +~~~~~~~~~~~~~ + +Released on 24 May, 2017 + +- Fix: ``post_internal`` fails when using ``URL_PREFIX`` or ``API_VERSION``. + Closes #810. + +Version 0.7.3 +~~~~~~~~~~~~~ + +Released on 3 May, 2017 + +- Eve and Cerberus are now collaboratively funded projects, see: + https://nicolaiarocci.com/eve-and-cerberus-funding-campaign/ +- Fix: Internal resource, oplog enabled: a ``*_internal`` method defined in + ``OPLOG_METHODS`` triggers keyerror (Einar Huseby). +- Dev: use official Alabaster theme instead of custom fork. +- Fix: docstrings typos (Martin Fous). +- Docs: explain that ``ALLOW_UNKNOWN`` can also be used to expose the whole + document as found in the database, with no explicit validation schema. + Addresses #995. +- Docs: add Eve-Healthcheck to extensions list (Luis Fernando Gomes). + +Version 0.7.2 +~~~~~~~~~~~~~ + +Released on 6 March, 2017 + +- Fix: Validation exceptions are returned in ``doc_issues['validator + exception']`` across all edit methods (POST, PUT, PATCH). Closes #994. +- Fix: When there is ``MONGO_URI`` defined it will be used no matter if the + resource is using a prefix or not (Petr Jašek). +- Docs: Add code snippet with an example of how to implement a simple list of + items that supports both list-level and item-level CRUD operations (John + Chang). + +Version 0.7.1 +~~~~~~~~~~~~~ + +Released on 14 February, 2017 + +- Fix: "Cannot create a consistent method resolution order" on Python 3.5.2 and + 3.6 since Eve 0.7. Closes #984. + +- Docs: update README with svg bade (Sobolev Nikita). +- Docs: fix typo and dead link to Nicola's website (Dominik Kellner). + +- ``develop`` branch has been dropped. ``master`` is now the default project + branch. + Version 0.7 ~~~~~~~~~~~ +Released on 6 February, 2017 + +- New: Add Python 3.6 as a supported interpreter. + +- New: ``OPTIMIZE_PAGINATION_FOR_SPEED``. Set this to ``True`` to improve + pagination performance. When optimization is active no count operation, which + can be slow on large collections, is performed on the database. This does + have a few consequences. Firstly, no document count is returned. Secondly, + ``HATEOAS`` is less accurate: no last page link is available, and next page + link is always included, even on last page. On big collections, switching + this feature on can greatly improve performance. Defaults to ``False`` + (slower performance; document count included; accurate ``HATEOAS``). Closes + #944 and #853. + + +- New: ``Location`` header is returned on ``201 Created`` POST responses. If + will contain the URI to the created document. If bulk inserts are enabled, + only the first document URI is returned. Closes #795. + +- New: Pretty printing.You can pretty print the response by specifying a query + parameter named ``?pretty`` (Hasan Pekdemir). + +- New: ``AUTO_COLLAPSE_MULTI_KEYS``. If set to ``True``, multiple values sent + with the same key, submitted using the ``application/x-www-form-urlencoded`` + or ``multipart/form-data`` content types, will automatically be converted to + a list of values. When using this together with ``AUTO_CREATE_LISTS`` it + becomes possible to use lists of media fields. Defaults to ``False``. Closes + #932 (Conrad Burchert). + +- New: ``AUTO_CREATE_LISTS``. When submitting a non ``list`` type value for + a field with type ``list``, automatically create a one element list before + running the validators. Defaults to ``False`` (Conrad Burchert). + - New: Flask-PyMongo compatibility for for ``MONGO_CONNECT`` config setting (Massimo Scamarcia). @@ -17,8 +970,8 @@ Version 0.7 - New: ``MONGO_OPTIONS`` allows MongoDB arguments to be passed to the MongoClient object. Defaults to ``{}`` (Massimo Scamarcia). -- New: Regexes are allowed when setting ``X_DOMAINS`` values. This allows CORS - to support websites with dynamic ranges of subdomains. Closes #660. +- New: Regexes are allowed by setting ``X_DOMAINS_RE`` values. This allows CORS + to support websites with dynamic ranges of subdomains. Closes #660 and #974. - New: If ``ENFORCE_IF_MATCH`` option is active, then all requests are expected to include the ``If-Match`` or they will be rejected (same as old behavior). @@ -74,38 +1027,63 @@ Version 0.7 - Change: ETag response header now conforms to RFC 7232/2.3 and is surrounded by double quotes. Closes #794. -- Fix: Multiple, fast (within a 1 second window) and neutral (no actual changes) - PATCH requests should not raise ``412 Precondition Failed``. Closes #920. +- Fix: Better locating of ``settings.py``. On startup, if settings flag is + omitted in constructor, Eve will try to locate file named settings.py, first + in the application folder and then in one of the application's subfolders. + You can choose an alternative filename/path, just pass it as an argument when + you instantiate the application. If the file path is relative, Eve will try + to locate it recursively in one of the folders in your sys.path, therefore + you have to be sure that your application root is appended to it. This is + useful, for example, in testing environments, when settings file is not + necessarily located in the root of your application. Closes #820 (Mario + Kralj). -- Fix: Resource titles are not properly escaped during the XML rendering of the - root document (Kris Lambrechts). +- Fix: Versioning does not work with User Restricted Resource Access. Closes + #967 (Kris Lambrechts) -- Fix: ETag request headers which conform to RFC 7232/2.3 (double quoted value) - are now properly processed. Addresses #794. +- Fix: ``test_create_indexes()`` typo. Closes 960. -- Docs: remove the deprecated ``--ditribute`` virtualenv option (Eugene - Prikazchikov). +- Fix: fix crash when attempting to modify a document ``_id`` on MongoDB 3.4 + (Giorgos Margaritis) -- Docs: add date and subdocument fields filtering examples. Closes #924. +- Fix: improve serialization of boolean values. Closes #947 (NotSpecial). +- Fix: fix intermittently failing test. Closes #934 (Conrad Burchert). -Version 0.6.5 -~~~~~~~~~~~~~ +- Fix: Multiple, fast (within a 1 second window) and neutral (no actual + changes) PATCH requests should not raise ``412 Precondition Failed``. + Closes #920. + +- Fix: Resource titles are not properly escaped during the XML rendering of the + root document (Kris Lambrechts). + +- Fix: ETag request headers which conform to RFC 7232/2.3 (double quoted value) + are now properly processed. Addresses #794. - Fix: Deprecation warning from Flask. Closes #898 (George Lestaris). + - Fix: add Support serialization on lists using anyof, oneof, allof, noneof. Closes #876 (Carles Bruguera). + - Fix: update security example snippets to match with current API (Stanislav Filin). + - Fix: ``notifications.py`` example snippet crashes due to lack of ``DOMAIN`` setting (Stanislav Filin). +- Docs: clarify documentation for custom validators: Cerberus dependency is + still pinned to version 0.9.2. Upgrade to Cerberus 1.0+ is planned with v0.8. + Closes #796. +- Docs: remove the deprecated ``--ditribute`` virtualenv option (Eugene + Prikazchikov). +- Docs: add date and subdocument fields filtering examples. Closes #924. - Docs: add Eve-Neo4j to the extensions page (Rodrigo Rodriguez). - Docs: stress that alternate backends are supported via community extensions. - Docs: clarify that Redis is an optional dependency (Mateusz Łoskot). -Stable ------- +- Update license to 2017. Closes #955. +- Update: Flask 0.12. Closes #945, #904 and #963. +- Update: PyMongo 3.4 is now required. Closes #964. Version 0.6.4 ~~~~~~~~~~~~~ @@ -378,7 +1356,7 @@ Released on 28 September, 2015 - Fix: Replace the Cerberus rule ``keyschema``, now deprecated, with the new ``propertyschema`` (Julian Hille). - Fix: some error message are not filtered out of debug mode anymore, as they - are useful for users and do not leak informations. Closes #671 (Sebastien + are useful for users and do not leak information. Closes #671 (Sebastien Estienne). - Fix: reinforce Content-Type Header handling to avoid possible crash when it is missing (Sebastien Estienne). @@ -552,8 +1530,9 @@ Released on 12 Jan, 2015. - Change: HATEOAS links are now relative to the API root. Closes #398 #401. - Change: If-Modified-Since has been disabled on resource (collections) endpoints. Same functionality is available with a ``?where={"_udpated": - {"$gt": ""}}`` request. The OpLog also allows retrieving detailed - changes happened at any endpoint, deleted documents included. Closes #334. + {"$gt": ""}}`` request. The OpLog also allows retrieving + detailed changes happened at any endpoint, deleted documents included. + Closes #334. - Change: etags are now persisted with the documents. This ensures that etags are consistent across queries, even when projection queries are issued. Please note that etags will only be stored along with new documents created @@ -656,7 +1635,7 @@ Released on 12 Jan, 2015. - Fix: PATCH and PUT don't respect flask.abort() in a pre-update event. Closes #395 (Christopher Larsen). - Fix: Validating keyschema rules would cause a TypeError since 0.4. Closes - nicolaiarocci/cerberus#48. + pyeve/cerberus#48. - Fix: Crash if client projection is not a dict #390 (Olivier Poitrey). - Fix: Server crash in case of invalid "where" syntax #386 (Olivier Poitrey). @@ -811,7 +1790,7 @@ Released on 14 February, 2014. - [new] media files (images, pdf, etc.) can be uploaded as ``media`` document fields. When a document is requested, eventual media files will be returned as Base64 strings. Upload is done via ``POST``, ``PUT`` and ``PATCH`` using - the ``multipart/data-form`` content-type. For optmized performance, by + the ``multipart/form-data`` content-type. For optmized performance, by default files are stored in GridFS, however custom ``MediaStorage`` classes can be provided to support alternative storage systems. Clients and API maintainers can exploit the projections feature to include/exclude media diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index ebf68d349..66c162511 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -1,75 +1,196 @@ -How to Contribute -################# +How to contribute +================= Contributions are welcome! Not familiar with the codebase yet? No problem! There are many ways to contribute to open source projects: reporting bugs, helping with the documentation, spreading the word and of course, adding -new features and patches. - -Getting Started ---------------- -#. Make sure you have a GitHub_ account. -#. Open a `new issue`_, assuming one does not already exist. -#. Clearly describe the issue including steps to reproduce when it is a bug. - -Making Changes --------------- -* Fork_ the repository on GitHub. -* Create a topic branch from where you want to base your work. -* This is usually the ``develop`` branch. -* Please avoid working directly on the ``develop`` branch. -* Make commits of logical units (if needed rebase your feature branch before - submitting it). -* Check for unnecessary whitespace with ``git diff --check`` before committing. -* Make sure your commit messages are in the `proper format`_. -* If your commit fixes an open issue, reference it in the commit message (#15). -* Make sure your code conforms to PEP8_ (we're using flake8_ for PEP8 and extra checks). -* Make sure you have added the necessary tests for your changes. -* Run all the tests to assure nothing else was accidentally broken. -* Run again the entire suite via tox_ to check your changes against multiple - python versions. ``pip install tox; tox`` -* Don't forget to add yourself to AUTHORS_. - -These guidelines also apply when helping with documentation (actually, -for typos and minor additions you might choose to `fork and -edit`_). See also the `running the tests`_ section in the official -documentation. - -Submitting Changes +new features and patches. + +Support questions +----------------- + +Please, don't use the issue tracker for this. Use one of the following +resources for questions about your own code: + +* Ask on `Stack Overflow`_. Search with Google first using: ``site:stackoverflow.com eve {search term, exception message, etc.}`` +* The `mailing list`_ is intended to be a low traffic resource for both developers/contributors and API maintainers looking for help or requesting feedback. +* The IRC channel ``#python-eve`` on FreeNode. + +.. _Stack Overflow: https://stackoverflow.com/questions/tagged/eve?sort=linked +.. _`mailing list`: https://groups.google.com/forum/#!forum/python-eve + +Reporting issues +---------------- + +- Describe what you expected to happen. +- If possible, include a `minimal, complete, and verifiable example`_ to help + us identify the issue. This also helps check that the issue is not with your + own code. +- Describe what actually happened. Include the full traceback if there was an + exception. +- List your Python and Eve versions. If possible, check if this issue is + already fixed in the repository. + +.. _minimal, complete, and verifiable example: https://stackoverflow.com/help/mcve + +Submitting patches ------------------ -* Push your changes to a topic branch in your fork of the repository. -* Submit a `Pull Request`_. -* Wait for maintainer feedback. -Join us on IRC --------------- -If you're interested in contributing to the Eve project or have questions -about it come join us in our little #python-eve channel on irc.freenode.net. -It's comfy and cozy over there. +- Include tests if your patch is supposed to solve a bug, and explain + clearly under which circumstances the bug happens. Make sure the test fails + without your patch. +- Enable and install pre-commit_ to ensure styleguides and codechecks are + followed. CI will reject a change that does not conform to the guidelines. + +.. _pre-commit: https://pre-commit.com/ + +First time setup +~~~~~~~~~~~~~~~~ + +- Download and install the `latest version of git`_. +- Configure git with your `username`_ and `email`_:: + + git config --global user.name 'your name' + git config --global user.email 'your email' + +- Make sure you have a `GitHub account`_. +- Fork Eve to your GitHub account by clicking the `Fork`_ button. +- `Clone`_ your GitHub fork locally:: + + git clone https://github.com/{username}/eve + cd eve + +- Add the main repository as a remote to update later:: + + git remote add pyeve https://github.com/pyeve/eve + git fetch pyeve + +- Create a virtualenv:: + + python3 -m venv env + . env/bin/activate + # or "env\Scripts\activate" on Windows + +- Install Eve in editable mode with development dependencies:: + + pip install -e ".[dev]" + +- Install pre-commit_ and then activate its hooks. pre-commit is a framework for managing and maintaining multi-language pre-commit hooks. Eve uses pre-commit to ensure code-style and code formatting is the same:: + + $ pip install --user pre-commit + $ pre-commit install + + Afterwards, pre-commit will run whenever you commit. + + +.. _GitHub account: https://github.com/join +.. _latest version of git: https://git-scm.com/downloads +.. _username: https://help.github.com/articles/setting-your-username-in-git/ +.. _email: https://help.github.com/articles/setting-your-email-in-git/ +.. _Fork: https://github.com/pyeve/eve/fork +.. _Clone: https://help.github.com/articles/fork-a-repo/#step-2-create-a-local-clone-of-your-fork + +Start coding +~~~~~~~~~~~~ + +- Create a branch to identify the issue you would like to work on (e.g. + ``fix_for_#1280``) +- Using your favorite editor, make your changes, `committing as you go`_. +- Follow `PEP8`_. +- Include tests that cover any code changes you make. Make sure the test fails + without your patch. `Run the tests. `_. +- Push your commits to GitHub and `create a pull request`_. +- Celebrate 🎉 + +.. _committing as you go: http://dont-be-afraid-to-commit.readthedocs.io/en/latest/git/commandlinegit.html#commit-your-changes +.. _PEP8: https://pep8.org/ +.. _create a pull request: https://help.github.com/articles/creating-a-pull-request/ + +.. _contributing-testsuite: + +Running the tests +~~~~~~~~~~~~~~~~~ + +You should have Python 3.9+ available in your system. Now +running tests is as simple as issuing this command:: + + $ tox -e linting,py310,py39 + +This command will run tests via the "tox" tool against Python 3.10 and 3.9 and +also perform "lint" coding-style checks. + +You can pass different options to ``tox``. For example, to run tests on Python +3.10 and pass options to pytest (e.g. enter pdb on failure) to pytest you can +do:: + + $ tox -e py310 -- --pdb + +Or to only run tests in a particular test module on Python 3.6:: + + $ tox -e py310 -- -k TestGet + +CI will run the full suite when you submit your pull request. The full +test suite takes a long time to run because it tests multiple combinations of +Python and dependencies. You need to have Python 3.9, 3.10, 3.11, 3.12 and PyPy +installed to run all of the environments. Then run:: + + tox + +Please note that you need an active MongoDB instance running on localhost in +order for the tests run. Save yourself some time and headache by creating a +MongoDB user with the password defined in the `test_settings.py` file in the +admin database (the pre-commit process is unforgiving if you don't want to +commit your admin credentials but still have the file modified, which would be +necessary for tox). If you want to run a local MongoDB instance along with an +SSH tunnel to a remote instance, if you can, have the local use the default +port and the remote use some other port. If you can't, fixing the tests that +won't play nicely is probably more trouble than connecting to the remote and +local instances one at a time. Also, be advised that in order to execute the +:ref:`ratelimiting` tests you need a running Redis_ server. The Rate-Limiting +tests are silently skipped if any of the two conditions are not met. + +Building the docs +~~~~~~~~~~~~~~~~~ +Build the docs in the ``docs`` directory using Sphinx:: + + cd docs + make html + +Open ``_build/html/index.html`` in your browser to view the docs. + +Read more about `Sphinx `_. + +make targets +~~~~~~~~~~~~ +Eve provides a ``Makefile`` with various shortcuts. They will ensure that +all dependencies are installed. + +- ``make test`` runs the basic test suite with ``pytest`` +- ``make test-all`` runs the full test suite with ``tox`` +- ``make docs`` builds the HTML documentation +- ``make check`` performs some checks on the package +- ``make install-dev`` install Eve in editable mode with all development dependencies. First time contributor? ----------------------- It's alright. We've all been there. See next chapter. -Don't know where to start? +Don't know where to start? -------------------------- There are usually several TODO comments scattered around the codebase, maybe check them out and see if you have ideas, or can help with them. Also, check -the `open issues`_ in case there's something that sparks your interest (there's -also a special ``contributor friendly`` label flagging some interesting feature -requests). And what about documentation? I suck at English so if you're fluent -with it (or notice any typo and/or mistake), why not help with that? In any -case, other than GitHub help_ pages, you might want to check this excellent -`Effective Guide to Pull Requests`_ - -.. _`the repository`: http://github.com/nicolaiarocci/eve -.. _AUTHORS: https://github.com/nicolaiarocci/eve/blob/develop/AUTHORS -.. _`open issues`: https://github.com/nicolaiarocci/eve/issues -.. _`new issue`: https://github.com/nicolaiarocci/eve/issues/new +the `open issues`_ in case there's something that sparks your interest. And +what about documentation? I suck at English, so if you're fluent with it (or +notice any typo and/or mistake), why not help with that? In any case, other +than GitHub help_ pages, you might want to check this excellent `Effective +Guide to Pull Requests`_ + +.. _`the repository`: http://github.com/pyeve/eve +.. _AUTHORS: https://github.com/pyeve/eve/blob/master/AUTHORS +.. _`open issues`: https://github.com/pyeve/eve/issues +.. _`new issue`: https://github.com/pyeve/eve/issues/new .. _GitHub: https://github.com/ -.. _Fork: https://help.github.com/articles/fork-a-repo .. _`proper format`: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html -.. _PEP8: http://www.python.org/dev/peps/pep-0008/ .. _flake8: http://flake8.readthedocs.org/en/latest/ .. _tox: http://tox.readthedocs.org/en/latest/ .. _help: https://help.github.com/ @@ -77,5 +198,4 @@ case, other than GitHub help_ pages, you might want to check this excellent .. _`fork and edit`: https://github.com/blog/844-forking-with-the-edit-button .. _`Pull Request`: https://help.github.com/articles/creating-a-pull-request .. _`running the tests`: http://python-eve.org/testing#running-the-tests - - +.. _Redis: https://redis.io diff --git a/FUNDING.yml b/FUNDING.yml new file mode 100644 index 000000000..ddfcec6bf --- /dev/null +++ b/FUNDING.yml @@ -0,0 +1,2 @@ +github: nicolaiarocci +patreon: nicolaiarocci diff --git a/LICENSE b/LICENSE index ac19726ff..dcfc0027f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2016 by Nicola Iarocci and contributors. See AUTHORS +Copyright (c) 2019 by Nicola Iarocci and contributors. See AUTHORS for more details. Some rights reserved. diff --git a/MANIFEST.in b/MANIFEST.in index a0d08bae6..583289344 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -include CHANGES LICENSE AUTHORS README.rst +include CHANGES.rst LICENSE AUTHORS README.rst recursive-include tests * recursive-include docs * recursive-include examples * diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..3cdff7f8b --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +.PHONY: all install-dev test test-all tox docs audit clean-pyc docs-upload wheel + +install-dev: + pip install -q -e .[dev] + +test: clean-pyc install-dev + pytest + +test-all: clean-pyc install-dev + tox + +tox: test-all + +wheel: + python setup.py sdist bdist_wheel + +BUILDDIR = _build +docs: install-dev + $(MAKE) -C docs html BUILDDIR=$(BUILDDIR) + +check: + python setup.py check -r -s + +clean-pyc: + @find . -name '*.pyc' -exec rm -f {} + + @find . -name '*.pyo' -exec rm -f {} + + @find . -name '*~' -exec rm -f {} + + +# Only useful on Nicola's own machine :-) +docs-upload: BUILDDIR = ~/code/eve.docs +docs-upload: docs + cd $(BUILDDIR)/html && \ + git commit -am "rebuild docs" && \ + git push diff --git a/README.rst b/README.rst index 82e3df92b..3d2e1483c 100644 --- a/README.rst +++ b/README.rst @@ -1,16 +1,24 @@ Eve ==== -.. image:: https://secure.travis-ci.org/nicolaiarocci/eve.png?branch=master - :target: https://secure.travis-ci.org/nicolaiarocci/eve +.. image:: https://img.shields.io/pypi/v/eve.svg?style=flat-square + :target: https://pypi.org/project/eve -Eve is an open source Python REST API framework designed for human beings. It -allows to effortlessly build and deploy highly customizable, fully featured -RESTful Web Services. +.. image:: https://github.com/pyeve/eve/workflows/CI/badge.svg + :target: https://github.com/pyeve/eve/actions?query=workflow%3ACI + +.. image:: https://img.shields.io/pypi/pyversions/eve.svg?style=flat-square + :target: https://pypi.org/project/eve -Eve is powered by Flask, Redis, Cerberus, Events and offers support for both -MongoDB and SQL backends. +.. image:: https://img.shields.io/badge/license-BSD-blue.svg?style=flat-square + :target: https://en.wikipedia.org/wiki/BSD_License -The codebase is thoroughly tested under Python 2.6, 2.7, 3.3, 3.4, 3.5 and PyPy. +.. image:: https://img.shields.io/badge/code%20style-black-000000.svg + :target: https://github.com/ambv/black + +Eve is an open source Python REST API framework designed for human beings. It +allows to effortlessly build and deploy highly customizable, fully featured +RESTful Web Services. Eve offers native support for MongoDB, and SQL backends +via community extensions. Eve is Simple ------------- @@ -72,11 +80,31 @@ Features * MongoDB and SQL Support * Powered by Flask - License ------- Eve is a `Nicola Iarocci`_ open source project, distributed under the `BSD license -`_. +`_. + +Funding +------- +Eve REST framework is a open source, collaboratively funded project. If you run +a business and are using Eve in a revenue-generating product, it would make +business sense to sponsor Eve development: it ensures the project that your +product relies on stays healthy and actively maintained. Individual users are +also welcome to make a recurring pledge or a one time donation if Eve has +helped you in your work or personal projects. + +Every single sign-up makes a significant impact towards making Eve possible. To +learn more, check out our `funding page`_. + +Sponsored by +------------ + +.. image:: https://raw.githubusercontent.com/pyeve/eve/refs/heads/master/docs/_static/invoicetronic.svg + :target: https://invoicetronic.com/en/ + :width: 50 % + :alt: Invoicetronic is the leading API for electronic invoicing in Italy .. _`Nicola Iarocci`: http://nicolaiarocci.com +.. _`funding page`: http://python-eve.org/funding.html diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..df8383e95 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Security Policy + +## Reporting a Vulnerability + +Please email pyeve at nicolaiarocci dot com any vulnerability you may find about this project. diff --git a/artwork/LICENSE b/artwork/LICENSE index ac22ecd6e..1963da290 100644 --- a/artwork/LICENSE +++ b/artwork/LICENSE @@ -299,4 +299,3 @@ WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU. includes additional rights not granted under this License, such additional rights are deemed to be included in the License; this License is not intended to restrict the license of any rights under applicable law. - diff --git a/artwork/eve_leaf.png b/artwork/eve_leaf.png new file mode 100644 index 000000000..cd01dab32 Binary files /dev/null and b/artwork/eve_leaf.png differ diff --git a/artwork/logo.ai b/artwork/logo.ai index 81049a552..c2a4bf83b 100644 --- a/artwork/logo.ai +++ b/artwork/logo.ai @@ -643,27 +643,27 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + endstream endobj 3 0 obj <> endobj 8 0 obj <>/Resources<>/ExtGState<>/Font<>/ProcSet[/PDF/Text]/Properties<>/XObject<>>>/Thumb 16 0 R/TrimBox[5.66899 5.66901 685.979 685.979]/Type/Page>> endobj 54 0 obj <>/Resources<>/ExtGState<>/Font<>/ProcSet[/PDF/Text]/Properties<>/XObject<>>>/TrimBox[5.66901 5.66901 685.979 685.979]/Type/Page>> endobj 109 0 obj <>/Resources<>/ExtGState<>/Font<>/ProcSet[/PDF/Text]/Properties<>/XObject<>>>/TrimBox[5.66899 5.66895 685.979 685.979]/Type/Page>> endobj 110 0 obj <>/Resources<>/ExtGState<>/Font<>/ProcSet[/PDF/Text]/Properties<>/XObject<>>>/TrimBox[5.66901 5.66895 685.979 685.979]/Type/Page>> endobj 308 0 obj <>stream Htю7E+DeGv P vAKDV画ZaHm}pzs۰np(kHCx^C m$i.Czk;/g#&栣p^OsRE5e祋ĸȨI XR~԰&>/ʄ6'UD&2HochE!)2\d3C\Pl?JZT|Zc"Ii^d`R=ҥ@Ρ?K6`<€J1B2F!z E2B9`i8ƐT\XZ3FڞXiUp @@ -695,7 +695,7 @@ H 7 ˰\\y\S۬T|j &hvp /S)zcZz9'7Zh[SlJKK5BkA^n93=ͨOR8x)h|wHrNEFDESp*/ZBqvv #h'Y&|mmGeUuMm^}ښJ56kAnNVFQNO;Ƨɸ[;esg&H)rOu7/RZDySya,/Ж?2tQ( :Ya[u5u66lkrs6w9[i޸f^Ddg:m\4aݗ)&#{.F"YB U!mD|yr!WOz;/\H*Mg-*)wT768;\[۽/]=ۻt:-͍ uU9#UViGq 9 &"KbȪ -j5=h"jt܈#t2ݝM70RLD6gb%k*ETNzTnKd ,K|mU݆vWW} |0xhᑑy##G `;\͍k*+JX3IJY$:R(~dXKdH<#QV;nhzyJY*EKZєE {ۻKR30IO)+*kZ;:wڳ##ǎ8usc;~3O=~lzumvnj\_(+.H>C]jOa'OJ6s +j5=h"jt܈#t2ݝM70RLD6gb%k*ETNzTnKd ,K|mU݆vWW} |0xhᑑy##G `;\͍k*+JX3IJY$:R(~dXKdH<#QV;nhzyJY*EKZєE {ۻKR30IO)+*kZ;:wڳ##ǎ8usc;~3O=~lzumvnj\_(+.H>C]jOa'OJ6s DtZ&6QFޞlwDdXTWXkN7&'Xo{[]@.Sqc\*2:Ř_hXWaSۻ[{wphg.\+W]_vg^0vяFP(/ 1Q|2"˦ ?NET֙6{u54ճQ^b6Պ8^WRXg)zSvԹYn7tjT,byKmzSkLK]]\^IwnЇ؅K^q֗wwGMNNχΗn_z҅3'G=Dlu9)ҢTZ)$͕9Q$}Z~66Hlij,+2jEa!^uWG/Ka"l1+-vMזX-$YlЫc;@p\d!vWi&s:sۮ8s7n޾{'~3Ocjꇧ͓_O>|p7]SǎzO?ŋ1=/?㳩>y<9q/S6gN|oOϖ-R[%#fHH v-"HD1f[˪];vp09:t{elܗ{4Yn$Bm$H>tdDZ%tlI4Jrɥr]OBh1Ƹ4c107״9 >Z͛8qXc檼Gw9Bdr梽p$@ܑ)gg&FdD?sdOW$Q&!~yTZD-ec<>9|/I@gy"*sێ!`a1nbֆo t{(~;&Gǡ3 l+/NyzҹS($"yvpPsp],Oߏ d;1P[]uZD l#odFUumC+D(}c7MY]{ 8&)YL-aI,X~ݬȩsl]nr㉽!<*b5qZL676TWUbEUYU]S[܊$)}4㏈S1$3*beGq!ˤ-+JyXWK}ǖ ?`9 nӎCmnE$W4ilćflf'ׂ>Ţ-wC6hO!/0!`ӈM~7lOڱi4!rPV2 l2yM$tQy}wp&ёaR'WW-f33SS`%'gds +^`J˱յ -xGg rDh>q!ˤ-+JyXWK}ǖ ?`9 nӎCmnE$W4ilćflf'ׂ>Ţ-wC6hO!/0!`ӈM~7lOڱi4!rPV2 l2yM$tQy}wp&ёaR'WW-f33SS`%'gds 1e:`C,O oN[o>}hewέ/>ɥW+Wฬ7 H2yE mwK嫴?.ة^QA~$=al Uvj{@tګʖnE~!MV\mZ{W^|CklcQ5tp{45>`G<@?Dhk3ҒDE`#b'&esJJMmb`q?43![Oh?CݿuR˶w1uZm&9$ tTB=e|cm=+;v;$%@N{NjFw59s B,IXΜrKT7wRCst* \_]y @@ -725,7 +725,7 @@ LvJ| 4W@DlrfޝQ b^Mu=.leT"Iygp!)twX|F~Ec0ggf}^Yzb0 ?>m%۰h зf8YX3g>/ w5Vgćy;YPsp4D(1GfIi`[R=̙[?eOukWse!!;YC,%K)+kPhDR֊VZvl)jM =w<9Gw}?u?dYʺ[jK2o^ ;u.";+#Rv(|rAl2NRf6bn-(k"I7c an&Z T:2 {=R]u+:㸅2$R5pIV?`vͬu383uOn^9fs@]ZP|-N7-G1Jxq[YE5-dyZWdc*+.ƌlF42~aM#fiჳg03!{=R]u+:㸅2$R5pIV?`vͬu383uOn^9fs@]ZP|-N7-G1Jxq[YE5-dyZWdc*+.ƌlF42~aM#fiჳg03!cSG%?(98֚' T!Dg^U+g{OjZǦg~79ArcgB z&pjٸHtn~$+Egk$ ?'8h+V/cd&pE*iE'{T9JVh(Ms @@ -808,7 +808,7 @@ Eږ͛ aiSK_6lg䙚1]kp|f~C`jJr3{?h,+." ѐ#2!AJK4 ^DRNYKIˁa1əe]+GgI>gmɊ he"m5G$,mhZk^&F\?p@MVDM-Z`y噖ù+cRk23 f4/39n%7' #uiQA^D{ C% L;#3+"klqū"S_)k#čuP?㖆#(RdDq -H(j9s9״Ʈɹ/(pWcE^گ!1VD1"-mj  Љ `WK)k]f +/x{/`v)< BCb72_(@ $0c.>A)w;glh&0ڒ짱`Ks:hSQaG<y#,ȤWum+Mv[]-w'V /ϸ +MU숳gG[{Wn,|hDOSYNRDbabpU& `B ; +H(j9s9״Ʈɹ/(pWcE^گ!1VD1"-mj  Љ `WK)k]f +/x{/`v)< BCb72_(@ $0c.>A)w;glh&0ڒ짱`Ks:hSQaG<y#,ȤWum+Mv[]-w'V /ϸ +MU숳gG[{Wn,|hDOSYNRDbabpU& `B ; DݦV'\݊}WRwhIXf'{ZJso\[6a^,+Ȳ#ȍCˈK(u_=<5?~@<75X{娙H%\ 1Мbr^ | mihYۼ"VN_Ե.K{av|urTE'+C Y%\ g.{72 *F,L u4֔L @@ -829,7 +829,7 @@ u ;wZҊNr0&Ӓ  ,/(οey''\cي)/y{bR,w/W0BX0P-Ŷ9+ *k+ o_Immh$GeYy  lv}E L ' llԇ(=Ơ vw몞<UNr3K ,-m3{O􌬌ĘpO{3i+b+9?A5)Y0Ttl}w߁Kp%;ꆦ֎n2:Lg.>.cnnnLMA10PZ+~)˾Uƒx:o65D]V dس,Ŷ)#8XF>VrA&z}c-;ݼ?~’5 MNR/:D8_ 0B -659)O>aFajvBSCMӒ»$B}{wn57׆+2pc[' ,2&6.6&2,Do6Nrm5Ҡn +659)O>aFajvBSCMӒ»$B}{wn57׆+2pc[' ,2&6.6&2,Do6Nrm5Ҡn /@ qq6kl1B%uu6 xeg\v葰CnvZ*k[q#d-WTV542>}6rVNGek/ m=}C0хb2:mxhpL""T?yTt?/'rGCv&`)+JBXؖWRhda F5%y8~^F4m=(sHDKMʹ}Aqɓ_+_㛚~EYw pg;00ܙa ( `B @@ -858,7 +858,7 @@ U .Jl6mHju,bU6+s hܸd-ʥ}wi-sun=0Ľi-_*)U_ˈb$na+;ϧT;ppA7C4.*Iߥa8Mm.ACi7\j|fiԫ)]ޭjʄU]3(í whJch-4x7h׿*P0H됎L랇ڡuÂ,{Bz}8vggҲd[!XTZZ.vlAg {;Sm`vؿ`~?ga. 3Ì{L^WYe4]L7ok!wI~Ira^=C#Zh`Wu}p)"z7ff&3$FJ8Ҷ5m @@ -919,7 +919,7 @@ W> l/i^3;iڐ0sĀZnS qW7Np:([568ViAFޜ~h9Pldüj2dO +61--1Ewv =JCHW34܏&x8,&#Rc3Dvz6RSyu_N/nmكvT֥Y˼?RFװKzn9Q4gC^5l`P\ܲG&ޫ` 9PҞٲXr6 -V4,{a؄\tcY`]lǿԾar鴯؏=b!&Yb ^[\aYt$w +V4,{a؄\tcY`]lǿԾar鴯؏=b!&Yb ^[\aYt$w [R)i[{$7f"o Xp zBz'hO|Ō4ǐ|-j :}̴a%Tv5Y9QK d0 ?$ćH|#uD3 phrd@,@XmVKY@ou([8#!OM~.7SoJn%|zC E T)f/:X1}J+>_~Q;^ㆪvs&۸>.k7yZS:˩㜍rݖۜaKa!l.g57Kv0!;ڗfe %]"XT J3aժlwVj=v姠αe=bI/gH& :g,(y 27>aba88fVVqɌT0NɉB`( _"fo! t}Wg_0}HX 9,Qx=~Jٹx>ӱe9M2mFS)Vk-eZFF٥btg0O?Dǐ%7eyښ6WSCyeUS}l`a8i g"1лJ"|PKڝc,$+&PvꖴGBoj_t4I vqf熚(eC!b׼^SbYi1¨;2W`/7uh?4 -!z@#(T 6 ^!R S#>E/Sq9z_ /G%ӈ0C9[ۼ@(٩P ,}XTOkpQȫUG6 x2e,> -?ϭQެYz/T5FL^`tީ3\#̬D:,vw[mDW)TBZ`0Ֆ`3tBQ˟kks41y `\޸cV#z`XHhwA0چFTyqӵܫ*F˪%*/>9 +!z@#(T 6 ^!R S#>E/Sq9z_ /G%ӈ0C9[ۼ@(٩P ,}XTOkpQȫUG6 x2e,> -?ϭQެYz/T5FL^`tީ3\#̬D:,vw[mDW)TBZ`0Ֆ`3tBQ˟kks41y `\޸cV#z`XHhwA0چFTyqӵܫ*F˪%*/>9 gS'"b'zL=N)cs*bR)W<#S 癛)K &L\9WtW!Y17i*%wJ_ 閥nWJ!p-0T`:K6B+SzlL,~J#ZLHBEe߈Eq1 ڸTD}bB;*OTCnՍl$OYQ0mz7o9NŻ|hDV[Ve֩b7YZÖHl~I)ܻJ5oOݑ%(,hZGҼmRd!/NEWutV57z;jjs^^lDǾ0-a_aL؁w44簍b^ppi&nX uƻ-݂ -cY4_g ?jGIfH %J҂[%ϩC6OzvWzoZtA$?z;ؼFT2/+0@@S<@>0bSuqw;j4S'/4sEթ(P[V^5ƊHkg/ۄw 0*֭ ajyB5TC J(_F4!m, RN ?S9 :״OfOV"յڇ1,V)S@._ @@ -972,7 +972,7 @@ e bk*ĉ_VTm }D51oUQu *nҹDU4|>WxA_PZ? Q y1>yK\.!OqM 0Cl];Sk)=RZ@[ɷ5JBeǐ$Ni"0 -úR4H~9.☫|Dϸah-)r~"eoMK%4 _7"‘e QD~0T.>"x*O>酧.Ey+HVy55RWsEk*PxEGB;(J X(8hiqmh^ 0`}_APWDLZ‹]<4zG֦`oyZR|u^gCF#nr)Va5ƪw9njyIt -xI1bIy>}-AگOShKFx6xqqQ +xI1bIy>}-AگOShKFx6xqqQ 3SU\ka椚̩Di~ ?{>J3mtߐZt]YNju]ɒQYlZZsNѴѷW>Sݥ0Bj+7q҄fU7m :8^;#eտ+*,_CY3MSU*LX.jQȖg_IWJ5a"9R'C\y׳qH)VU-Z.\+Ѥ/aen/|F[?SPkr" ^Y>VH9 &yaIxQfd}+] @@ -1008,7 +1008,7 @@ tE=H Wh*¥{I/1YwûJy׸jk@p[z3*ReRXwq30u%BAŒ%\NC)W'5꡵ &F+U,d5gR "JrVDBSDO]V[EסdyVӃ1,+Iev"`WrwKaG|`%+TVRCF{Ys*Z5߬Res Q3 jQA4Ӌ<>$.7$C$pA)hJewT*FmKg-lm*{{v\ܲsJa>3_*ݑہ>V5|WG_>RR_YL!RFjz S5fځO2< `}I\:XiZkRH*4[(xX$u|I9̺TkVzl_׼gC%*wXR nY)N.9+wZ[E9ľWJ%wp`Nj[.b|JOsdW,R~#* ĽyFdwCp*L(8OelL˞)A vfFʹ.Knd~A򥾺]Di(i]YʯJߟ?>w[侾7KK6w"!eDp5V* 3VEa{:KoEDcɾJ#oOU44lTjFk,>{S?ýSk>Su=|j}T -SU.nk.mcŮ)RxbT<TV*yÙ<+`RC;S^0-itp<ȗ2IZ_0ȡVVKHWol9=fd jb%}DCy{sI*{ZL1r`n}+D_*Uz3}i779_kjxL+u ;FxL.mmQ`sKzK#>&ޗxiBV^\s3_XX_رC+ҭj|S kϽ|j|[X +SU.nk.mcŮ)RxbT<TV*yÙ<+`RC;S^0-itp<ȗ2IZ_0ȡVVKHWol9=fd jb%}DCy{sI*{ZL1r`n}+D_*Uz3}i779_kjxL+u ;FxL.mmQ`sKzK#>&ޗxiBV^\s3_XX_رC+ҭj|S kϽ|j|[X ΆBL.?\DCqߢ7nO(M&JOiݖw0IJLM,NCOYPoQRSTUVX Y#Z:[Q\f]x^_`abcdfgh#i3jBkRl^mgnqozpqrstuvwxyz{|}~ˀɁǂф{pdXL@3& ֜ȝ|jWE3 תū}kYG6$ڷȸ~kYG5"ŵƣǑ~lYD.оѧҐyaI1ڲۘ}bG,{W3qHvU3sIa)\ Z,      !"#$%&'()*+,-./0123456789:;~<|=|>|?}@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`acdeefghijklmnopqrstuvwxyz{|z}o~dXMA5)ۈʉq`N=, ٖɗmZH6%ؤʥwog`ZTOLIFEDEFHJNRW]cjr{ĄŊƐǖȝɥʭ˶̿*7DQ^kyކߔ ,8CNYcjnoldVD/h 2 @@ -1076,7 +1076,7 @@ w%5 }>z}-R|~H(oYpq݃^s=uPt;bvSPPx <>Ay-|0m{opzrt?s^auQOw+T=y>,{¹luSmoou{psGrlatqOvk?=txj,{ @k mܖnlprxqؔM`WsNuȌ=&x,zj׫4lgmomqq0_s*9N uI_|2so|u]}@vLO}xT;"~z-*|Ly(x*yyr z$y gWTaˢĮkTd@D\dPPp-HG&]30;sCg( 1DE*n6ܵaz*&>P3ĸg| ,X񦁓`S$>BG DǕu#i#܌-`xJ!wم:(`[HWeQ2UFD`|:Cd2~TvkdEeUb2̽p ʠ~[@QdF!7H$ #dLt!BOK*G-iCrB.UlmO> ,B2W<+367ߛ@ )۠&KO 0ޏO igm82=D 4FB[!AIb4~Z *fz\OtF&ӝN&3xF[Hjz&3n14bM zB! |+ +NL؏SEZLnÖ`=lva;(>̽p ʠ~[@QdF!7H$ #dLt!BOK*G-iCrB.UlmO> ,B2W<+367ߛ@ )۠&KO 0ޏO igm82=D 4FB[!AIb4~Z *fz\OtF&ӝN&3xF[Hjz&3n14bM zB! |+ /hw{V\lsTjg?қ۟u 깮D}û.5ʺ(wM ұ=Ljeo(u\ yPXƢ8p2232"uh0 ;(3-ybݷ3WdsF@w ,8#!H*9)iF^ P7Dg3I33D_)JQNdOm2ta':=J.۱ s`d+uu- ǵiȵ\L @@ -1088,7 +1088,7 @@ v|@Uv lEx9 {XfPg@C_[G=/5g4ʥ^E*z 5#p&XsY>>@?nC)HKс#Eu$%`^>[ (?`~^x0_+OËv&"YD>s5x']~-if~>NF" P^OG# ǖ0<7ӆ7 :sXL!kݱrx{6Rt"+@q*7k1U誘Y}(~\H`J䞂\ 52[{F;Onݦ *C{2Hpuw0D(MHOB$vKѻX{'V' 5c +}3!JH$#h^Qv0qUY:ʫaZ=V.}VZfsy ֧MP8:x-kն(+rީGSIЭO wiι9јy&z8,k.$x=rmRMRuMb;dw0y 2֤T{WƊ6m|+8EC` Gd]Mm"WrS禜D~AS +G6W#jnA>puw0D(MHOB$vKѻX{'V' 5c sh]T4I DGãTD(2BNlz9eB_ ݫ.#JUbGɰ Pc36߅!3?o/˼ 4Ta1l-vKWZApɾ<>\Щހka8Z5$GdW#{{ߢ! e8l&Vlu4ʚ@ԸQWJ"쎛)9(6gf y'1?JL)b쭢l]4LkۘPpuﲹ)nCA Ŷ+2dEH'Hm&Y3uѷkѽӭ1n]_Z<ڮRvӛpjm9G݂#j}dA-uڠ 0\C"dhK>مٸ:IFq\BVhF'$[I&3BtK\ D'`;I ["%#N\I @@ -1186,7 +1186,7 @@ K.?)- c7Rg4tFZgaCCQx!)(04e) T12Υf8E‘6G V^؃R(E֘Yՙ >6."4Fm Iz9)d1 ź F+)mju@a7gDfFiUcԝRڊXxi>6|XG/@@+$kaQbќ0/nMҋ]%:c!רZTxY jq4Fּ]Xyw?=5a'v:u]㌵u=,"@n9 $$!+E@AHGBBpEA."(hA P뷙ӗ}Їw oPEiԑ9qͩ[ q)Q<\Uh.gY}WS(35QEJYj)zS h/Pk<^~'?aS| A :8}F/R+|cha - 4Y^HjZU7 + 4Y^HjZU7 [C1 ?w<}Aw{_Kyē]Pmp\+ؐ- TźˠRVYĐ[tX;-i(i7[9GPq4zg6@0=4kֈ\c-MANTij *A+7V |ZQ4fmld/ 5@ ݽ#]w̋Usri07mN wˌ|!WQRQIc fWlerU:Gg&{ q? n. |f0rg$u͚B869A$Vˊ:bVoi L,EUJ@!Og)Л@v4>4=A[+g $fy4"nv,9r1gJc:5J-AYL @@ -1298,7 +1298,7 @@ T^e@ il8;2-̶Ľ8r՗roو Q?z5YMAJ"KA 5 *#pL6#-pͶz7ӦJWn]Rc&S٥";H+,%p jHVJbe)Qa^b(,D y)|Z)qn3כ X)a zmVoRG,K)kȫvٕɎ|3LV&V%XU?@Uw(1ſ!1Ő(ZeW0Wi x6}=A{a.'M6eKȞ&!>6!$.ݙ[+tOfUUFW#ȑWy{R"wypьÝs8>Zﵡ7"fi-hgMoKKiIuHl7Iz7QCi -n +n \+k{'B>p6?7{qevCd]@?ߓv> eЛbw8Gv廝xw{S;|)W[E?r/~V迒g9jfjk`s@=aSN3w1_3"ܑН]QM^i@AH ,!!   aȢ ѶNjkkGfܵ"hE .qj3/s{{sfW/=4rl4:&eUԉU'br(PV_}P#>NW8,9u >K~i]ԅ܋/a坟ÝyDUD^Rj NOD{Z\oO#"V7ЊwXN)iQOͿjr˹jʺZ\25/$7'6}&o 7}״Gm:i=ic l:;wP^Ս Ϳ㌊|QMD[}fpNۊ<zǷ1tmk|cm_blԶݜǸv ?6OvwP;;ye*pALdRԩ3vΰOJuvuO*vt/v^^ٳK޳[s.=͐^cHzak=U>GhùwK[w@9(+JcԾ"_L+)qZ;@U=h̦E;ȇ#J$ëpKi נZV7n7ˁp;8]~QBi8 c>H7'""zBJ*'T"}kC]dR!EBXd/48pܑ~p֑ ͎,xx5quoC('u"4c )d $L.9t?$\0Q ‚̷C|n Pݠ}f>g#Ѕf!8w @@ -1363,7 +1363,7 @@ AC :U3PŸ@7QMVen)wr{q]yMWՌCkp^øZsƝ{=fKm`f9/c)QDy P+Kz?'\z?#qnU듸c>;sC V}ҮBܾUXfLXD%L3lw`φ1H6G[g\qǜqy,wy"y_sW8-q;-v\#,s:Jvv:;9:wlqfLm|N:h{u A!8bnqm* [u_epKؐ**2,m7֛l1l5)0.7TJ6 W\:dk\^V2Yg`(vF#9. % }#cwJFscS[ŋ6-X f%YZ=_ڽXU9 ֥t'+mZ#PM88>(cEV~O8qT oDѺk6+Y"ʐ-ʑg{fzmS,maeye//L:}?>4sЬD}>͟Po ;`k@xry`A1Zѓ٣L2eit,ET-RBg}[~=h(1:ӑ (X2 d 3lj2|/N&I I?Wbиlqr1_5׸S3Xejf<.iSGCp" -80(ٯ[u^ȉ̘AȈҢ}QXqR9Ӥ'S E ]|j)ǻMk"-&1sT?pjPEq췍Ҽ3NZ,ҿqBj;(v<.@0wlpvL8!f)xy\ԨLȵ" uyGEuqwgfd`.誈i*e60 URUZb2XYK(nQ@M\)GO-hknQ999s{}b<31=uO\u]D1D[~:s[<ס='ˍykP0e P0I(HҜy2s&3.N#56CiuXShvNޠGGp>36o_kE QY|7jdYc?4bIQ4I\tl-4 6)1D")!ΐc/T+b۵ \z/NFŋ~>\3T`'ٔuy%&G,5E^rR!+ea򗤚a6IѶE +80(ٯ[u^ȉ̘AȈҢ}QXqR9Ӥ'S E ]|j)ǻMk"-&1sT?pjPEq췍Ҽ3NZ,ҿqBj;(v<.@0wlpvL8!f)xy\ԨLȵ" uyGEuqwgfd`.誈i*e60 URUZb2XYK(nQ@M\)GO-hknQ999s{}b<31=uO\u]D1D[~:s[<ס='ˍykP0e P0I(HҜy2s&3.N#56CiuXShvNޠGGp>36o_kE QY|7jdYc?4bIQ4I\tl-4 6)1D")!ΐc/T+b۵ \z/NFŋ~>\3T`'ٔuy%&G,5E^rR!+ea򗤚a6IѶE $}LR¤r'Vaܦ 7w 3wY`%Rf5Q|'&`_ԥD(9IQ&1S9DqpŚjdt/ a44ztc-Mh`yн\g̣:0+*"EPEaeXT7( .ǚb&Zq_c5֥1xXҨZMD? \{0t^>|߂3s1TG9y%41W1~PŌV1V$ٍ6es[2͔-WJ3-WAMJ`?fr1 6 k`T78bEqgO9+h`U9Kq&(%a,pFIJHc0'+ ?:cx#%S3=|K!1'tTN쉽/[P%5)J)Iq$[d-`.s\ŧ<+SJM2ZbSI Qg[)#Si)ZdQ5DJH5ʜ4LiK+Rm9-QtzƦoԘТKj0;1Ue -v~ۘ7m]Č~2g V|F2-cY1YVEg56@cehKoPxve +v~ۘ7m]Č~2g V|F2-cY1YVEg56@cehKoPxve G r+^eti)̇ ߶L3zhL9eHc_#yVR!: qq)ˑLS,yJO-QZZRL#}R\ z@IGeǕ6|W<h5 ћȅL|}^d+ W\QhŔX]tȑ_$4(c,J*t=TO\K%7MEF4 gR]AQg]wEЪ(* --, +-, BmăD3iFUi;1&ͤNkNc̴L56i֣c,d?Y罾}FL+`WJQdv|dȕQ Jv\*C ~;+ιOcqX^8V±`>( *id_+;IFYIJdT'[y*u)ڋ'/ыp| <<_h&q;(@1τ;~$J ~dʼnJ**@0 :3"$ * !yURxP JlL_qÿ~Llu1JXbPt|R.Fz#ìCH Njų#aKgpK-/p PH9ĜE̓}O?/Q_µEgKO F+k+:w%KF.(\/Qu`;ϰ-DMT\~vPBsy&1O _?f4`9VAZM.?Ppxs{Ez3r [d!m\@̳p}jΫ)$C7XlaX?X6N`LM6s6U|RMySpw+TQ"͡|ի^3uK a·A? XWY q/O=r, w}qKCM~'q~g<>,O ڙzb/ku?#|agD:a/Caq0&Xku7F4(8!8G䠿&M sA ";`4"hu&x`x?NsfO8)w /:r΄;M6HhD9pɈH#88rpu\,b%% @@ -1541,7 +1541,7 @@ Z b nUƱ3h:Z+neJ;=HYB6BIH@P !Ѻ/NT;նK2x:ɇ0p=!?}f^LRpφ`@Vr@G Aw"0<A!\ŜԪX<71 1 '#hGw_C0" 5m ṫ` ",B",BPGbP !BpS/ t3Ϟߧ"$/0` %:BrXa`F6;XApٕVb\r>i:_PK -:G/Ґ9c+.q|h"|X ~5.5uбFl 0a|x=u04.zE4)x C$Hl- yױ;'jn i\ W8tl-бk؎nA pNlEMlaY6{ר` Y;y80_w97=Ecg@Ҁ= бQR$Ή {P1j` B΃Vݕ Yk`Õ(,7U -U+'F|` +U+'F|` ^EMB@n/+iQ'B/ paT/D;C!XB"0cr>Q88/l0݊M?xy~n07|cǎ0q)SMs^(d^^2l/WYn_zWl۾ܵ{"ވ־o|#G?>3L6ğ=w>1)BY"D-U5ڂ¢CiTSźƦffpvv]|nܼu}ŗ_o~OD%}y1<\'_ gK"0X8d$ D0QPp)#`@L6-F8n#mO@zH(=&c̾dݽz~x FEyy = % G X'$`(,K?W-=C o"[ ;=Qo;p0ȱ4Ï?!Idr -bXwAWM1 0 z޻}_>xo=z;xɓOkMuDT__ba~CٖsJ:CR Z G#e&\WfHKi h0a@À 4 w|kfdKeUh_ݯAųs94HASe *g)AxӀ n_ToO*HSoTb.W]ޠZA Р%4(ײ3n膆>nE$YL!`*_mԝ/QsР 4y"ySIfuaƹgc,i0,5pCu~S9Ѡriȇ۝+]xWY"Z:ӸdM3^Dv 97V0N6CC4N۝#>1tdBG*@C'ie$5hͥotРUrS!\ʖrz$N:Ҡ#{脆ƒn#Hi КʷkJ A˱)sNy6K"cwgI=q:E+6 Zg @@ -1637,7 +1637,7 @@ t .,* l!!!{ I 7kKGwKU#-X+:uA=zL[8 B|潚|w]=hil*5{.]0wp3GN RqU"֘[>asbOn"){>G6bڸ-Gx}HY|HC4ЄaX(AQ> a@TNq Gq2͓$ߡ(2)*%`8z dE!; qL.}6D3e|4|Es262'aqh/Ȣhf3 2* (\GAi,; <As -Ru t:3ALd> 1y +Ru t:3ALd> 1y J ' JCʀÄF KTaP-!DXK/ldAV'ɺ.g Ivg|[xbd=xM4d'ѡ`1IgB'^9pGCI<ے!ٟ tNf@x&v.Ywg!>Y/yB t&xCȀ. &E [D(@/8nBܖ>BE<C!ρ ُQx /(#hPy#o1&BPPCUꓠ4 ʝ =GBH#3 KGR9 &'}HNJ1&QOn=[}KAݝ <Ϡ#4>(:qLT}å A1(Iy -|v{8TgP^RWhʟk4Owyw:?.)4½a#*}P23L}*QhAd$?ҵj}jzoW ˦QӅQ9g0"7x&XśU@|e渱jGʰs)wtuV+neEc88ᑾx_~aKyrpf.l=tГ|{]Ċ:&N'ؐ=ա#1+mWU]GF&K_ @@ -1697,7 +1697,7 @@ B, *22XPHTAD @(JޔAB6=sfκ޽{b OMbCx-'I?&s>XߎM9#sdkQ5nBN y\8 <; ?QB+ y#p!uNxʶ [Ÿ] X&wg<%ݫ:0/<8S6|n:9@틼H뉸Axh|KD~F!ZS4.y} |&t3I l}#fr+Ȧ0k4f,9nD$s& J{jUwQ1k n$o<.x:rVȖQF"vIv$5 -Jst0k울 NeNEOU{JX( Z0D] +Jst0k울 NeNEOU{JX( Z0D] (ަi0E&pJהFߍyǷ ʣl2v2&%ݵI ť3ɵD K%)^U / @@ -1707,7 +1707,7 @@ E Xp;O눠kXMaщԭq-5ǷUWFRW%TVzeRkYE;')O'̝{/!s[Y)(J"j& pk0hkZ1i8f .ZU*+{H˔Ԥj<|/_|b +.1]$[=gp{W#vVvYB{>bc'ٸQ9jU#'!@jYR.:S%񫚙'+|*'88|"*;R%S"h5[KLqf`34&w3T1Lz-#6-.Y(l5+ȼ&WdC#- n Va#FpV#ZX+*_ͿE{Wp ``#6ფ!ly +@N{Ss\»JC:՞A=q;mAԣ͈zL(Auy{oq`w0@-vвuq1Q -q/xl#GN *v:s9>Վiq\r@ o/"s;ٿ}52GpsgN kdӻ iWRX0o39jUmW;'2w(tێLݒc} 9. ra ut 4|$@MH3v;b=IQ>as7[MΦ[sf fjvg:`Kږ:duȎ1{\E+WwA'@?@ίXΟH m!f[Bਞ_l쫏^'1)i}g6Ky+wVn|8x8]Mh_ο-3'pC"HvY(9yѡY&/J9hZru3W/~,=A}ny;P gD.~gЗL{(m# a!: 5px7?ՙSa20 f`FP"JQ,X"q%Uc jtE=.Y{uƵG"%( -!;O}}'~$~0Ofh#v^R+uBW e{; F;m_ x(6Q}اD֍"j)]5GPps`|(|H?-"")bϏ߈5X/v~nH>6J-߳* .C4'DD8?( +!;O}}'~$~0Ofh#v^R+uBW e{; F;m_ x(6Q}اD֍"j)]5GPps`|(|H?-"")bϏ߈5X/v~nH>6J-߳* .C4'DD8?( А:H>0ZArCOY yJLX R`Ev%,M4/q-T{cDAD 38Ӆ\Q缮kD{xv;a="zNϤB 4MC rfh a]';m$gxF[bFl6_7 o7䴺)AU輺ɡQA5h8AzvV,Ns!eL83 Gx*NgLбB㐱Um kpooȱ>^AwP~1?OH1Łi=3LL{յ3OǨޥzZtnT!ACӷyFsh"D3\p-Ds8I?DMy`%6U" lBgE b eJ2L^U++fMOe?Y-k7g]ew+bG)F)O+a5Xs\3 )ς@x+܊f֟btRk(j/˔? 'ODT up~ `$lF򙔱xV2eы,?xO{*PuAo_t?_#?%7j`X~|0^@0WANx絔Ahieޞ`og?hΓ|9g|Ht7B|{`'  zh%hp440ppX%B0H1Bo FʗRQ>= X=Q[LɅCy+)hEˉH #[!`|E~\BAYpS8RB7(ˉ -ro }bL x`B/Hb͇C<hƠ3̕A#z jAM,H`Z&)&5t>2L$U)}~D^ KK0hȠ ]̝ACo l`rI$! 2A%r|INeJvv :2hOZ1[•XB\RJj٨B: Bw,\'u}GEugqSFA"3u +ro }bL x`B/Hb͇C<hƠ3̕A#z jAM,H`Z&)&5t>2L$U)}~D^ KK0hȠ ]̝ACo l`rI$! 2A%r|INeJvv :2hOZ1[•XB\RJj٨B: Bw,\'u}GEugqSFA"3u DPAd230 ",BK5ZWcM=hbY-b'su߻}9(zy'V&q_ Nq%]ev^Hihde-r8hQA:'hE"[|}mqBLb?ǖ( zŨ-,rw( e}ow?$kxo%7WCgҋ_w?=߷{'+E;oKQܒ(['e8s21E3fNPxpz]8oW.Z ?Y̬ Y 0/2]7\ g'\e /p@w$@/#@oZP/^z~>+]}A&ݙ;U'Eb;w>3_q)0JƧ(:@38]z~@Iw}҆<4{~ެ>;ܛs\Z&Uٳg7'dY>=x5qχ&G<ޚ~f

#z}b!\ C a ZdC_E yN68=qh~y&sL?ݢ?`xOn>A]gwd-MwN6]V@A`Wal-pM9G2p:ҋ},b>H.p ,ݨ?$Ev/6߹r{Z6A[K:K7]`'QkԱO/&f~e%<疈JGT؃q=ѱ{#4=]7nmtۯ6lM%YK#٪w͡hOPc8O7cq>_'d$8,d_۝P=>Ұ;.AԵ$lSlEGtmMֈ6eY˩1sC9z:N(#5hWұ0e7gRYp" S'g67c{g7'upKJFu=1Ŭ-![ܪYĕ6/Yn"UVѩ6̥2+yy]7Li :Ƣ8н}I ڍ0۔)oS1ņ,؛m ;s䬞l/^g\Pu1$U)&uMCR.־:acE|sejkQ)Wjvţ3q$2 @@ -1739,7 +1739,7 @@ PFF (2UXEj/-唞ה^dޗd3MD1AJ^W%fA=X4By#45Zѫ ޥ~E@C]S_kͭif!azSz;\Yu:\YHUITf"P _]AxkC?4 -`Cz'f,@w +`Cz'f,@w ;kW j0\Ž-nؾ$mˉuY [uMeW/ة)ZxM* u]xpNA{&q38;p;@57h~D@t[ۛ NDn^>pW BCȃz`uP y2cc}8ܻy3itu` cOx>>ޏ;x}~lFຕ@Cq \֥)bJr:ɣP-g< <ܗ\;JܖᦼUp8^E' 霽:'8^vMm 9ò7RN-F 6`|KZi |A|lr :)wr(4KP -Q~RpBpNˆ/B׏܆hDѲ!|PNc%|a#hpJF0ߕKżbxLq㤸Sn~ǐ Ѐ (%]%\a8g|JHlHOq4di X65|ՆoexhE0W!Д./n{y೎Y|< scZlŏv0 :rEE0&u٦0k@3nIk%e o!ya|HsvB!'KA#KYd>`]*Y Ճr\tuL-1GlkKx_ o8I/9kA!h.\c 2ꄯMS~w9Xeqrđ&fNjn q/X6,ao=puV?&kyGC&g3dL(9!Qjgky?ۇG>-})wžk) !#6ko,c\ɊA(fC~yCv&ړ{OK߹F*JyW=烀% qe#3pH\΀j<9y{@&/|N:gT "bc|'ku4Jg-_-__߃?[mrrz{ҿrGQ @@ -1921,7 +1921,7 @@ TEr-ZF ElA,DBHHB@¾- @%1, { E" ZD|>^‹w>LpP8:H"Ol@H4@D E> Eߧh x 3{ =p~@bIb)y`o%65~) }OztGr( yĆ_ x {L|@mT+5s7*Nȁ3GR]xڅ)|9x^d\ \dyb.pPdmkkm_"8'q*)c{&B?P|5 -b} \hXL35j1|%/h`?b!4У( ^@9 Sa6r%'Pb 5A(=)|FIS|F!'AcIn#V4jBkn    {C.bE aԞ=ag*"tVP*GhHLf)sUQ͚H%PY~5[y6V!zgkhpwK +b} \hXL35j1|%/h`?b!4У( ^@9 Sa6r%'Pb 5A(=)|FIS|F!'AcIn#V4jBkn    {C.bE aԞ=ag*"tVP*GhHLf)sUQ͚H%PY~5[y6V!zgkhpwK Ly}\DM3 3]WdkijJ$#d=U$yA 7B-P%P_6`ׅ3$}N+5AIL62U`#%yWFSE E[V\Ks2[nh`ԱCbx. 蕽|Đ Cű-NV>ߊ=jVy& ޠe=6 S9Gg?2ɃVEnvK7rk. ^H /usYq[[ 7sbMFuaӶ9Y k -U E┆u9&NMit tIn3 .3^9w^SǰVՑ|פЭ37\X%XQ"L{~:ܮԶʬymdmC9TeZl7$chI-if+ qA3$MScCV{n Э _}#/zq|εIJŕJ*-A#HOr{kJxBrA7좑ܭt~NZn2I##rߒwYQ۞7<{7ú{cDfknNS2KR2LH.$ &zrX艨Y? ^3C \?bY>.Ԉd#hݐky˰qooLlqd)jMlp-:2{-O)zIH8!J"HX>--t?x9g#0 ee:@i5 !e96̰p\C8hՇ" `Bʂ,R)!*Bk ;[s@\#/x7){4<ǃ.fqq!cBE1"ST,B%W"=U^jo3v+w)n࿡) >HmW] f{!և‡ T\cMPCj1de6C!>Q DHhu«)5QʤL @JM 0&jTًH CָCR6 Qrq Zلq鄏 gLQغ|AACil2} fl)2HϠY_8!e+<8!vCKľāIp1\h"G$wH @@ -2024,7 +2024,7 @@ s 7gUTu|L)fxJ.ǽ']yAoV)D K1 ѕJi=,?', uҧXKStrGÐHS"2(Zl#76u{]v2ξַre~j>9XET2K^Q9A~E6fLH@S@V@((C^ d!o5n3aNKǪ]/wt9Q~uqXN-Q1J8yj^NB03S?*?g$z$ {s羚P?llew]O~{GI=VkȉŔe&tir:NfQJ8Y9$Q)JRR^y:m<$HX|\^ԯsv-K.O}ݪ7rV>VANfgf1ӋjMOu^>/NPJԷ$?IIeH\@\k X -:G.5yionm>KWg(Kɔ4m#%']ƍϬfeiƴ$:7<-3.#%. lb$ zob0[=\&:7;/j;E W_v_U*є(ɧB-K^P•Djω^H;)ʋ*|˔|J d!"5.7 Eg0ri={vUM-ݥ :gv_cĄr5-4uFxb$[ܒ\A?LQGl2d=iv'poٻǩѕ7w6qiD>YP|]>&Q*4qw mWB-PA;?k#t{h5h0phUw-uHy^/;,1 +:G.5yionm>KWg(Kɔ4m#%']ƍϬfeiƴ$:7<-3.#%. lb$ zob0[=\&:7;/j;E W_v_U*є(ɧB-K^P•Djω^H;)ʋ*|˔|J d!"5.7 Eg0ri={vUM-ݥ :gv_cĄr5-4uFxb$[ܒ\A?LQGl2d=iv'poٻǩѕ7w6qiD>YP|]>&Q*4qw mWB-PA;?k#t{h5h0phUw-uHy^/;,1 0idҌ,M6ɍOhoht#$1a-0 pF;0r]m3`fouw)^lw+{/J#E] J ˒B'Q:*(v#-3>xJ÷!a m̝`,߷A ,hrO-i~%s0ɇv9-t9(ax@!p`9 l ,n0aߌ@v;(ݎ[G%];1MοÞOʾ\O%(wƁfdlfGmrޟ~n^BL Ѿ"4 I\dLCpfbc!Ń5RlOh0P¡Ej9Nh8b#MN dBBgCbrDd9CVI;hdFo۸O@p꣞Sc>k ᳐ENCP@^ᆌE gސlB@|<:S!RԨ/Do/G [1|l hf;U:A=*$(j='os背f2N/d~~C]'^OaM)^Rq|m$ y$ rȋACrr]3CS2.TkyP~@ȏA~6dJ |:a9z[ gBƄ>c8i80 :W=79>Эvc4ۂnAPAu,lȗQ!ѿ)^E*T'* d#d@G4LX( aaV4D{%1K튢|O"Ə y%~Gnwv?DsdڐT ߳`F5}E=z&L`dcn= @@ -2032,7 +2032,7 @@ d b}_`߻FܽR|~vLU k,WTy|zߜ_);qc2i@Vs%dM}Q ỐOsڷYVp3? c> tF}i1\Ci`mrkU{*7iw<狹_(o3~n9h2m:oHXնf>L0?I8XUA)ғ`B(@( =jjA@P((2눸zQ 3{f{vV|>_NnMF*1&8xxot~ |NΌъXQuTY٭9.}|gWF>UVJoO&51/'&Tſ NZ 4D#/C +2TVkrRtLgve뢰%1Gz ;ryr)R~1)ܿ>YƪLa&KEļus->TC"{٘p#W7 ưnA:hO6zIw VzE':J 9U*%IeQ'Nt=h/L@ -TP![ ד`]tk 5]Rwҗ]&ok7BۣKq-IM79'LWBȎr0yL, +TP![ ד`]tk 5]Rwҗ]&ok7BۣKq-IM79'LWBȎr0yL, M1e?0Y~rD#CdUV&z 0_@]=hxVr⸁pD0`ƿޟ:esZdjJh*dAC1b)VO(P T{kn~x{oeFgC5='ݼios~)wC,D쏹k5t$9ǐ27zO17ml36E|blՋ6<ȕ~WNP0- 7HP5 #(^C}lgIqLO΅sd?8{ &`V`ǘ9f32g̠촋#:JR%n+Wq gC5(~/r!Z Ɯ% Ygf~,"/|&x6dtmGUnߣCnO6p`sY9P@ -HUY.B )RyLR7*71[hP),SOeNwen6sew,~^p\O;Cde.|-{2!aYb3V]5+ꊟJJMWӌV}(>o;6kb6ە/a+~*p<@k:> }Kې_|4kC:(r:k!T 5C^pZ>}w <H[_Hh \~:L:IvMQ" ְD{P9Jڍrw2Iu|u &9+m8)@ g)kHE vȜ` dYTg;Av5&@ $$$6!)67,E*n8RA[EQ}k=ťӊ֭Uq3_ۙx;}srpig0 bɴA$ @@ -2078,7 +2078,7 @@ n Fsr-PZvs-9!Ek)rkCFg\ktqC-sL_',IwK]uҿIoqɰ@p'؋iqꞀٳRcviy]+!hf-yA&uJUtYJN2ϮJQPd68f4 孢ByVM#vϓtE5 *08LzпdcE0`]EƨZ?A-E3ɦ@Anܘo^fUδѫTKTܒr8α kPIR(ܳ7ܳ^r*s1/7;Lz>q' AR :Jm(sEM^ğPи(Ƭ Ų\+gjm܂RZW#P BUQVW-Su=#*x!Tc' jLޤQ8' •\CT" RFM.P1ha, 1(6-+Id,[MQPSV ;ej׻ȵe=nRq4ew8],X\iP%28Z2 XNvQSK髂X& Z:\Q-Qg9(J+FuiWIcn(YS üIwypB{a4ܷ 7XFU7(oE42 ÍbrkS,klEV^UM*U.$W4 *\ݢXq04k 3!A4(ADqpU֩Z -X+ +X+ 8"8KlUk+.D:Zu}_Z?y}r?hL1+'Ǫ$;LZ_3~Pec^:A?iÖ8g~&h;+Wƒ&^I>7AR9{u{d*`}¬4=f15x/j\jѫ;|v G X{EwϔLmn5l%$ ݓWoy?8lJeҁ529ega:__qڋQq=C89NwnXۚ]2xuj8QgCls4و<+al܃?/ b, q=0DGcr504/"~}Ts{re.r,EvܜGD7H}zQI;q-ri9Ѩ }>mmV ⭱5^tWtSYNq̟Xbߢm_6*m管;k莿+gs' v}8 .B- 8Dz 6PF mmmmdۤ}hͮnyc!xP:շn+9 d ;H΢l@@ѺEA0[TV%=вdKƠE4++JDuIU>%Kſ+ T Oﳿ&3{_3[_ wE .R.uCKɗ"ۡrAWY E ] ombj/e?fSXhf? rh^U?mwfpػ>pbP٭P؋!vI/3xG@S` @@ -2102,7 +2102,7 @@ IV 鈛݄p#?(Iёe=>1E;w<;A&W E8>UQ1=H?y,NxdJ<2uQ-R.iOeEBvWjz/+/ x=K{+~rK NX2Z*L-!Kel%]ϒ%#/X |* })v\UlSl}Mbc#?4esZ 4tU\q/Q]}IEcdOΔﰦ)[+ZW(7[sUͪ #s5oPtU]*60>kt&T Q?wQ=F*Nm %4N)h"/_WfWdkr6hvج o"nYo̠6ABmАc̿B$Q~<)p0EaWHiCxڰܰъ_({NV ^ -]dLk$d>=H(aAha^S}ZO#=vn4ݛjfWpj/s'Ϡ?FJ׀7GbCdr#H91Pf蛤^'Ygi3lz2 h8;8R}J_#6{܎~f췏l:lvژȉ醕1aRVtYFtbaʅ&-jiّ" )+G7Niq4%CrcG ;ғ=FYcP'pFnXoEF|O v"-6Q͠hfLΈIM=ߐe41zWCR[c@a [5{砚}>)8 +]dLk$d>=H(aAha^S}ZO#=vn4ݛjfWpj/s'Ϡ?FJ׀7GbCdr#H91Pf蛤^'Ygi3lz2 h8;8R}J_#6{܎~f췏l:lvژȉ醕1aRVtYFtbaʅ&-jiّ" )+G7Niq4%CrcG ;ғ=FYcP'pFnXoEF|O v"-6Q͠hfLΈIM=ߐe41zWCR[c@a [5{砚}>)8 |`BV `)-,5!Z>ʔULM7]?1nݗbWq\>r{c ;ғm|/#Y.h=?goÌX<5/e GAkТ!#@ Az@TBt]OZa]-3umn~L _|?~i扫t$))2k89ǹ0ՒJT2k7gk[=LڃYSL^&3iH$%QS{ Krٻ>5`:d1UKkR$iAzc~97⚣[XVu'4i^ԛ4#uNpK J?sYIjeC?14LӱظP\!?kԜsr2\ VAZwmꔌ5I^Z Iz-Y/(bkی8(bq1;¬Ay¤c> xc&;b|G:1SYQ1#:As9|ҩw X=|}鄓2v q~ x́GO4=ˠ5½ @@ -2141,7 +2141,7 @@ eʔ$ iG}dc͝> 1cx@k[Hb[fB+:q#1&.헥{Rh2q<3I+s#kvxa>Y=DlvBP&-~,"d%ĞXVjI 5bԴc1ZiCvZ3\o1\r{y{lb>Kz 4&Vq.]#4"!RhX0&>'dӀ~M}̽5G%]3G%>4G%VhdeT>` 38E<gTJ&;iHbR48%LSh@jT6Q}Ҧ+:mҲ+3m),)tUShShdArCc#˰ Jsz2gکOzguStV_ٱ -ώS +ώS ˙МSPE9kS+c͹,2L/RXݲ|އB}0 f8*]A , V@a Zƨe|,3mIU7"ue<-\GacFgWA+%r:!-;klql}Q3dcMW2UP #[yL@^RE7_W?7Hq؃R)`+5okTg/S |!adg,@PՑXuw\ xº2s/)kS @@ -2191,7 +2191,7 @@ c;E즣(3 D !L`0!J{e7#bɽ6ɼ0Xa,L|qzJ] PSSm$;8D'!b8 -,FI> d0 1y7ȹf{5"Iq[\9 N98|_%~ / .) ._\Z!,8 ]u'0B(5wN FO3朜>dPg\Ҥ}jCtrt\\ȯkK8D??8{=<<wrx\O &5y vh}q- t=! P Fj0ؔf/TdV [=v]Ku_}K7펝ץc+ XASZQvg+tB-l7?ckncgX>Ntho+|+{n* ^k踂?t\B{lum29wtt"w71pyG\Vx塿ۏa @@ -2257,7 +2257,7 @@ uk 7gaV^ha,ӽ)C"G`CAN yb*c4Z̲IhWGV LgMbgXޅ8|DIQNh<)OzDDF(73@tiiNi63OLf1;CuIمQ|{b _?Zj`&ޱ.v?._Ə ς(38яWҗ>Tz.?.7i<%oD,!`R8\)`. .jȥHB@H1%폎@TXb/&f:.cK4#1wsb=8|LfҖxxCCxt $2N(mt 5&j0T?CpmG2aEh9K(U/0q&{@AkX = =Y&zfͺ uЭ>HV^iPfPwމЋlxH9,4ٲ5f` ,x808!qM٠)]I l"10BTI##P$Ccba܍2Sc5#&F&G;Τ-gҖH#D >[3F5b( Ab${izз9&l^}p"F;b2!{asE D&x#8j$,byb!p,dLY]ّ!1CHlp\q .U%NLH-Rdch ^@D3Hvgxq|Dp*жhcHu}67jʌ -?R#3I3< PS,> ؞Eq\=-R'6;9IAzɆٜI6|XdA,@W־+Y?[ړC iBF-(ӊ -A[(oq@j ȡ^s8j$,AE$h~?Xhڊ>ǁ-•a0|!St+R)5D@*zmahCFnlV7qm͐pnyQњ+{O#Ok R>5y]Nbs0 ;P^84~EJci ؞Eq\=-R'6;9IAzɆٜI6|XdA,@W־+Y?[ړC iBF-(ӊ -A[(oq@j ȡ^s8j$,AE$h~?Xhڊ>ǁ-•a0|!St+R)5D@*zmahCFnlV7qm͐pnyQњ+{O#Ok R>5y]Nbs0 ;P^84~EJcil)%dtUY#Wq€rFtGz](9dj_8`]భKJ7HKwsؗ1TT..(rۮѵ}4f>z{ϟࣵLAϻsƌzfzkfL(քC ~h?j}CJ3E%/c_TVJ*pT_xEy\_^Hڨ;Wi YA"ҭ[l!Iv^يR9$Vd2nqy>=/<y;s+Nw $ ӟmWy0\*c<0gלuN@B! +G[Yu?R|^rrH/坑,~$K]Kn`l=Z5[7q|gUnr"~F8ߛ-cY đ\ೖ-K1Es)`[>zyH]PF(볫ܤ;dqFV Lk-zPߔJK{wWy~P'C8d,ߴ. :J@7 dzqF@` V" 6X ##  ZeWŔԃN~a~qfu#E".lйy.?Xϊ ;m HK=`(tu4G!gn_:^!B@zhCLZ8l$@ @@ -2295,7 +2295,7 @@ H 7 ˰\\y\S۬T|j &hvp /S)zcZz9'7Zh[SlJKK5BkA^n93=ͨOR8x)h|wHrNEFDESp*/ZBqvv #h'Y&|mmGeUuMm^}ښJ56kAnNVFQNO;Ƨɸ[;esg&H)rOu7/RZDySya,/Ж?2tQ( :Ya[u5u66lkrs6w9[i޸f^Ddg:m\4aݗ)&#{.F"YB U!mD|yr!WOz;/\H*Mg-*)wT768;\[۽/]=ۻt:-͍ uU9#UViGq 9 &"KbȪ -j5=h"jt܈#t2ݝM70RLD6gb%k*ETNzTnKd ,K|mU݆vWW} |0xhᑑy##G `;\͍k*+JX3IJY$:R(~dXKdH<#QV;nhzyJY*EKZєE {ۻKR30IO)+*kZ;:wڳ##ǎ8usc;~3O=~lzumvnj\_(+.H>C]jOa'OJ6s +j5=h"jt܈#t2ݝM70RLD6gb%k*ETNzTnKd ,K|mU݆vWW} |0xhᑑy##G `;\͍k*+JX3IJY$:R(~dXKdH<#QV;nhzyJY*EKZєE {ۻKR30IO)+*kZ;:wڳ##ǎ8usc;~3O=~lzumvnj\_(+.H>C]jOa'OJ6s DtZ&6QFޞlwDdXTWXkN7&'Xo{[]@.Sqc\*2:Ř_hXWaSۻ[{wphg.\+W]_vg^0vяFP(/ 1Q|2"˦ ?NET֙6{u54ճQ^b6Պ8^WRXg)zSvԹYn7tjT,byKmzSkLK]]\^IwnЇ؅K^q֗wwGMNNχΗn_z҅3'G=Dlu9)ҢTZ)$͕9Q$}Z~66Hlij,+2jEa!^uWG/Ka"l1+-vMזX-$YlЫc;@p\d!vWi&s:sۮ8s7n޾{'~3Ocjꇧ͓_O>|p7]SǎzO?ŋ1=/?㳩>y<9q/S6gN|oOϖ-R[%#fHH v-"HD1f[˪];vp09:t{elܗ{4Yn$Bm$H>tdDZ%tlI4Jrɥr]OBh1Ƹ4c107״9 >Z͛8qXc檼Gw9Bdr梽p$@ܑ)gg&FdD?sdOW$Q&!~yTZD-ec<>9|/I@gy"*sێ!`a1nbֆo t{(~;&Gǡ3 l+/NyzҹS($"yvpPsp],Oߏ d;1P[]uZD l#odFUumC+D(}c7MY]{ 8&)YL-aI,X~ݬȩsl]nr㉽!<*b5qZL676TWUbEUYU]S[܊$)}4㏈S1$3*beGq!ˤ-+JyXWK}ǖ ?`9 nӎCmnE$W4ilćflf'ׂ>Ţ-wC6hO!/0!`ӈM~7lOڱi4!rPV2 l2yM$tQy}wp&ёaR'WW-f33SS`%'gds +^`J˱յ -xGg rDh>q!ˤ-+JyXWK}ǖ ?`9 nӎCmnE$W4ilćflf'ׂ>Ţ-wC6hO!/0!`ӈM~7lOڱi4!rPV2 l2yM$tQy}wp&ёaR'WW-f33SS`%'gds 1e:`C,O oN[o>}hewέ/>ɥW+Wฬ7 H2yE mwK嫴?.ة^QA~$=al Uvj{@tګʖnE~!MV\mZ{W^|CklcQ5tp{45>`G<@?Dhk3ҒDE`#b'&esJJMmb`q?43![Oh?CݿuR˶w1uZm&9$ tTB=e|cm=+;v;$%@N{NjFw59s B,IXΜrKT7wRCst* \_]y @@ -2325,7 +2325,7 @@ LvJ| 4W@DlrfޝQ b^Mu=.leT"Iygp!)twX|F~Ec0ggf}^Yzb0 ?>m%۰h зf8YX3g>/ w5Vgćy;YPsp4D(1GfIi`[R=̙[?eOukWse!!;YC,%K)+kPhDR֊VZvl)jM =w<9Gw}?u?dYʺ[jK2o^ ;u.";+#Rv(|rAl2NRf6bn-(k"I7c an&Z T:2 {=R]u+:㸅2$R5pIV?`vͬu383uOn^9fs@]ZP|-N7-G1Jxq[YE5-dyZWdc*+.ƌlF42~aM#fiჳg03!{=R]u+:㸅2$R5pIV?`vͬu383uOn^9fs@]ZP|-N7-G1Jxq[YE5-dyZWdc*+.ƌlF42~aM#fiჳg03!cSG%?(98֚' T!Dg^U+g{OjZǦg~79ArcgB z&pjٸHtn~$+Egk$ ?'8h+V/cd&pE*iE'{T9JVh(Ms @@ -2408,7 +2408,7 @@ Eږ͛ aiSK_6lg䙚1]kp|f~C`jJr3{?h,+." ѐ#2!AJK4 ^DRNYKIˁa1əe]+GgI>gmɊ he"m5G$,mhZk^&F\?p@MVDM-Z`y噖ù+cRk23 f4/39n%7' #uiQA^D{ C% L;#3+"klqū"S_)k#čuP?㖆#(RdDq -H(j9s9״Ʈɹ/(pWcE^گ!1VD1"-mj  Љ `WK)k]f +/x{/`v)< BCb72_(@ $0c.>A)w;glh&0ڒ짱`Ks:hSQaG<y#,ȤWum+Mv[]-w'V /ϸ +MU숳gG[{Wn,|hDOSYNRDbabpU& `B ; +H(j9s9״Ʈɹ/(pWcE^گ!1VD1"-mj  Љ `WK)k]f +/x{/`v)< BCb72_(@ $0c.>A)w;glh&0ڒ짱`Ks:hSQaG<y#,ȤWum+Mv[]-w'V /ϸ +MU숳gG[{Wn,|hDOSYNRDbabpU& `B ; DݦV'\݊}WRwhIXf'{ZJso\[6a^,+Ȳ#ȍCˈK(u_=<5?~@<75X{娙H%\ 1Мbr^ | mihYۼ"VN_Ե.K{av|urTE'+C Y%\ g.{72 *F,L u4֔L @@ -2429,7 +2429,7 @@ u ;wZҊNr0&Ӓ  ,/(οey''\cي)/y{bR,w/W0BX0P-Ŷ9+ *k+ o_Immh$GeYy  lv}E L ' llԇ(=Ơ vw몞<UNr3K ,-m3{O􌬌ĘpO{3i+b+9?A5)Y0Ttl}w߁Kp%;ꆦ֎n2:Lg.>.cnnnLMA10PZ+~)˾Uƒx:o65D]V dس,Ŷ)#8XF>VrA&z}c-;ݼ?~’5 MNR/:D8_ 0B -659)O>aFajvBSCMӒ»$B}{wn57׆+2pc[' ,2&6.6&2,Do6Nrm5Ҡn +659)O>aFajvBSCMӒ»$B}{wn57׆+2pc[' ,2&6.6&2,Do6Nrm5Ҡn /@ qq6kl1B%uu6 xeg\v葰CnvZ*k[q#d-WTV542>}6rVNGek/ m=}C0хb2:mxhpL""T?yTt?/'rGCv&`)+JBXؖWRhda F5%y8~^F4m=(sHDKMʹ}Aqɓ_+_㛚~EYw pg;00ܙa ( `B @@ -2469,7 +2469,7 @@ z *vQjT?g_3V*Bm.Q4;}R9O(?-C Fx>}X10j =<}5 dlD`=螮PPcpVī£B!wxAJ<.%zD-Rb$#/5y%nHVYL!ϐ,ɢ#]rujWҙQv3h *34I3?hpfXM3}k2ed7]>S{ȟdl_vg5"sy{x9? @!*ÊU"-Q j_ Kڄ-o>k/+0>?37hA(ήz=gp]sp2‘ήG1΀/q,p0 ?pW.Xe.id081,WZ1]0sޥ1 36]`g,LoaX3-5s?f_dMӱ0,S ![5ި|2=x[0c,)MJ>AasO_|IӒۏqfGtS۝-:4y[$ y!I[Έ' 2wnYwc OaA>0k5bv~I,O 8?yŁ*:Lv;%;B9YeNqSIeI%7!s ʙv.wəPyR'zZN"f7jM1%ؚS,tz) endstream endobj 299 0 obj <> endobj 298 0 obj <> endobj 296 0 obj <> endobj 329 0 obj <> endobj 330 0 obj <>stream -%!PS-Adobe-3.0 +%!PS-Adobe-3.0 %%Creator: Adobe Illustrator(R) 16.0 %%AI8_CreatorVersion: 16.0.0 %%For: (kalamun) () @@ -2829,7 +2829,7 @@ endstream endobj 331 0 obj <>stream %%EndData endstream endobj 332 0 obj <>stream -^ˉǕ6c,!j# -oV?޼K]~-L΢ +^ˉǕ6c,!j# -oV?޼K]~-L΢ (N ս<}V>k=\ʇ͎_>v^w{53jf9'9C +%|ßNb:_8Ju͗YғG]!^νaY_oڵ0k5٭\wfpzsn?Ns%Cr(Ľ6ZGl@KRӴ^]ed2g;‚´/آQ'nԠfdJ;郋ÂNCh>0$ҒX` 04g] nDž),poynB X h X\pܜfڟ㡣yoNP zofv7+OC-kH}tsqcU/טLZRYe1SQhT^ӲPVLܕj A?KL.V#5-N:Liݼt!ЉQ1TMYÚfʠVMױd]^{9s9%tK=H)aHyXP9?(.?l7;?+9lwR>%@tG^dm \Q|\hGebgfJk#ݕuz(;imlXiFh}sk+~jYvZ\zsָ*K~Rt#IJV<fE쬙61慏=^Yajk_IpyaL2Zd\hi=i` Zu$욕>EC}UԎnfsqYXSWŴ:84]?3tŻsX!7U &zM V=y[mq`!ڟKd{ZCĥKeŶ߈kw7O}m"V| mŪr/qOۏYi -RNnTS +RNnTS I\QKcmը"~!7} n Ba;[K}-B\Kƈp7ՠ \nF{RuKEqXf_73 Vy֎ZSIcCtjfd1py.G/f y63oVoO@NZ2gQS(?Q1`a4Tz9 q&8Bd@m0^vVeܺxS=?R QיYh^qR%^eml963i'gB>MvuL`9c9}TÍr?SPDO'[WKcef!U-(P_Ο?ȿƜ"w#xڵo7^`eީ'i)[jQ'e&%ϋq*Ǧ6ml|1uІx$)SN)&W̑I6&~й?n{'WU`ldKVf~6ƈ_#X@$@hcR6ާwh5O1swu_!K3]=Xr K1i> =3?1чz;øk%ƻ6&sk[^+}OͶ .Ls~/,>2K݆w `8wrf8 2Ž<(3A2_P>g8}ĭ `B`X߁R'o+ RbsA= p-<_1Py6lÊ2.ݬiF*Oàk^58{6)v4?:%H<= '#wq巀mgQT2^7a] `;: 0[Xe8Hɺg7j!xoq&+Up #$e-cՇ e@8M;.|9zxogXa(bL^BDzU{o @@ -2941,12 +2941,12 @@ o 𦰄`jap ;7"Q7D^cjhV3ٳ&7#W2Vądˬn&2@NgE{SG6A<:,u&Ch.0ݳ?q/n;^r7]&u-\:/5DQSձ$DÞ1ڎ&wL_[dwt b4Z۾מh(%D>O37F8P_ZõvKD>&EK79DutOqV %f3MifcPMt`%.>ɽ䎵S(DQO8 =~RY<1V!蛬{L;W\Y=\KRh8EL!giO1ݩmo|;xKWx^irzFy?&qx_i6щsŹ‘c`2rbs?yh@M-Z)!PHrӐDңrY}q)ۻJۥ絺mZq[;C[o6 kyKAoYb:`99>![k1߈%JR{Y|ȗ$nl:fNw`әx~m6s">k8?ȼ8lq^}~_Bx_I" Z,Km}|8.ω0mzfܗ%#j9 3[kZr7l~s'tz7җ[(/4-Cg4OICuB\́ 퐫}Jjf|٤fSGIP_YjW!1;K6{]dϳV>ℸFW=6߬NL嗴m/siFy'&ROZܙyycQK JF{E c 7G+p f;0*4#z_z7Spvt[#b/jrFqYK^)t|F˾2xvy+|G?qͣ,G$6:5}m8GimrUo [c,k[xkT\zt] }Xe܄4JmNwc<=VN'g c< N[^&.EUs臇wrEq {Y6b{n(>GaQOH_@!.}H7_]xB}]?u/ d UЎm&eduvRTS#!ØurKΰCvH2W quUsrbvfrRN q~R+LqyrϳɃ4:S#7{t7O^uev񻶪,hR(fvf6, h3vX"ޓɫ @@ -3079,7 +3079,7 @@ I dN.DI2i{mݫ7<ۧ6lzj ޝaBYNr=6Ǟ+f:j0hM0m{R66VF~1|Ys DvOFChP i/٭Y+狟s8ޮEmԆZ0n}Gv|mo?c1!xs!VŽDGQ3CGd Z ;4GF~~ENqwd9Z×wE ?~?XAHXDܑ_? "D//Pe 4K゙Oe?c!)6Xب^XW}b$˧-|\pNkM0"3ak+fBs2*+JzsZW2F-f-% gv?MFqLU_ƽ\.ӱy{NNk~=tgiss< -#2OvQIݚ9dwEs#ϒ|ovd K +L[y-و+;%ك;g~Sp&R6Z\bOV  :{/30'xBNt@:G= t=K<&]܊*٫`$ }c#z@uZ7"wbݚ9dwEs#ϒ|ovd K +L[y-و+;%ك;g~Sp&R6Z\bOV  :{/30'xBNt@:G= t=K<&]܊*٫`$ }c#z@uZ7"wbC5v[չ8H7Xv zBן-+~5T2Loxyi2}זU fn|ueN=oL).2=q>`k[>[/~ ٯ'fS_KOWAe #x3\4O( @@ -3189,7 +3189,7 @@ $KM )ׇi ;\#Umc\MXւ5'6JcF/R@CdD̘VO{bup)l(|oa8[hV6Eq9{V*n28"K ]%YX6y%7ر4K/ލ:^48u <as\݋ZyOpT|DRgOUDyfp+zclzPwnt>++5ـe;NYnҙ)>Ϡ+c/; =ª\]|Xh *.Gkxt{1zSTnz!*p+r!xP*ҼLUdL#+*2#"]aܹHLu*pM4qcg yj`rl +9Lq6ktPn7[MCh@ǫ 6^{{_o_RL ,1Ǘ.7~+{oF~Jѕc;hܱ׻wSzZT(prbXW~:W8I/0&sA}Hk{l$uIʽ#6 V1Vg48FHc B S@; ^  l9>u*pM4qcg yj`rl 68Wa}l:yjYc(c(@YJP-ѡN|:@[>~yT ZKaO Hjy]D^*ڕhKmNA!tڝB0 zOYb<ݪ%]Ftnxū@e*^6T.mW Zb$WӶbgD#Lm`-TIcI|B*xZt9[LvgKc X=wHzb,5E?ƢX"]:OٗJE5urum&_Zy(g߫f+Z_6!K- ۄ4B*o@  ;#+s @XD'F!blx&dy< wyiSbn_rO(vnA:MH`4S+AI-fXy$:X$飠nIe^ 21=FQ.ۈ1YR㕭iSՉAxnԉ0Gf!>H[/UKt4Vo1Zz==`_g*"v3&p@@?  7 eQ @@ -3200,7 +3200,7 @@ $KM d;D2rDa/TC EyW4([sa/OmsZP5TP1UD HSF8ɫJHYdOMHXz@P8:"[-SDZf*?{o%XNjce9뙬{t8镇PIq2x8g`m"˽ŝHdk$`WB@3:.Ef!mM Xf*Y \UaC) ժӝۛckYs:ͳJכ|W8<2ά9[>9ևm֡ mT"m}i{C̰o7VDHnK hJUrKZi- !&ЄM*UQAUH n@j5 -C,-Hj5Zk!hW| ~h WDRޡfg ~T&i.&iz"Zf&?7BE!w"U#)٭%SwQQIÉE}|:ٗPkx,Nʊ\Ƞ9^$MG.jLO[O&(Yⷣ2`ÑhZ[(k/f"8ЈN -? +C,-Hj5Zk!hW| ~h WDRޡfg ~T&i.&iz"Zf&?7BE!w"U#)٭%SwQQIÉE}|:ٗPkx,Nʊ\Ƞ9^$MG.jLO[O&(Yⷣ2`ÑhZ[(k/f"8ЈN -? \_]?/HLw;ZS3^,ƔCPQWN?T@635r,ޣv tX<4q 6Lلj>t(ݵNkbeM]L"YI>[dSfә׸\DCȼ  hv[JRd/JB:QLF}oFLKܨ$=|l6}> j•3Sk9EBO(Q#*isu`zP7`]"ƗP+D P킒1|1 zZ;8l7k{Kõ07^ҫvPj-#n5MkV"XIezמ79g%lpK tyOוvu7Nj٢i j \+M?o|Wg{304E,UFЮ!iNm\O$<=o fTͮGmvfH쁷Aʳ=6o~ܝ.Lr:T^J;\3%ܟUg Fn ήaQO!b8AIz/ɳH0N' ,`oBql>d4mzB A4# M->oE[kf㞊,PCM+tQ7vNp lV<#[@ oҢꁺ~"jnNp:Xj.ӸwqB*$7.hJ$nĨ *7 ZoI ݮt̜xhklӝk4V[9[VX=~Tk@]*7uw\h`of=#bQTm-l;ҭJ7owK7g -#Q䚥q5qoX.lt +#Q䚥q5qoX.lt VƞWڍWH7j)W!H7+!tn*(ߠwFoӡ\yيr560jW ]5+J-&t7$N]y<4 SvCVhΪnA vC<42[{|8{{Fg/U0Fɔ<49X Z<`k u8>pQ3w yY\ǧ5АPGBKhɆ䝏҇##lZO;2פ:AQ:';5 Mli ^:5(}ؙr`~+[ir4lҢSdex0,fA՝{>VzAgjrS1%=bfR%'$܆wn+S{=7w@k\EΔAZ$M ՚vuYb2duKo!Mobf{ȻІX=עpŻf-l9F 0Da(2{"AK|t |X>8OƆ.>q|o\p>i,E5p>i, ]|',YX>)84nx HKfދ:]}qS݊IJmÉЉ AkL'RHQê W/$tXB'ﲌ)f׻@o4p'Qy@ gi_M_}'\2`<w:K4NC!+7?YHA=Cona H4v3W]"MxJ~̽&7vmsnf=5;oLV2-?Mx wg.2@JCM|s 䒷ezm&HѤ s#$CӮkF= +w#U<@xT4 ad/^e yFy׹R:A!5PLp!ø^6xZˆRyQn͆$e醞]4гWVP6":v5;oj>"Go7@v)O؏DaYfƤߏMkp2Mt?6GIUH8o3"ޏL\W2qek7^& Z޴!WOjY-mv_M}ɰ*Р5YV YiC1|z;Qb/ɶ^m$zH%k݌ /@ܛD]PXCDO>DekAyo4HV:WyPܼP}à@Q2^88f()ڌbRvp~F @@ -3238,7 +3238,7 @@ vg6 wAQ͝>pu۽ ؇B!y ؇""ͳ>LyMe !u3>TS>xycm`K5q_C<> ` -u؇BI)PPj+s؇Cds> 'uwӞP}c+}^/L{-<=_ C[Pg؇q+P}>;ت}2eP}")#[mDfZ;k1f\Lߍm^\xO>I2YUW>0_چu).H MNɽ%qoNݺ*䮖6R̱5G]\ycMy6r vZҏad^}.}ܐ<;Trdf#Yo +u؇BI)PPj+s؇Cds> 'uwӞP}c+}^/L{-<=_ C[Pg؇q+P}>;ت}2eP}")#[mDfZ;k1f\Lߍm^\xO>I2YUW>0_چu).H MNɽ%qoNݺ*䮖6R̱5G]\ycMy6r vZҏad^}.}ܐ<;Trdf#Yo ﮴c1R]x.Ji1w`N2VnwW1B&ٷjL!CHn#R{DU U@0A>pɁcR}ǖ^y˛? 2:!Ja тbpk>1C=?!3^(Y;xEq[:QdE.[6W͖}qJ&J #8HF%Njm QT)2&7"08Tj߹P;K+avE.Fn dCe؊A@D?QD=+Ks"Fu,U𢨾1RN%t6*,RchxcԂ=j]?Qo +)1RyUQ `2ܟ+5L}׽`Ť˥*,k~`8|uR2)Ⱘܜ9|!ޭ0_o,֓iDVVT)sdL7)Ȟ"qF>] |3#dNnn>o;rh$%_wH<\k^$~C䉮.ؖM&^&JgHӷR[iՀsdxP^}wɻJ+?nf%[a&ٽl@fh %:`m*=F-,jNMK~t(ßPM oCuF3*S_jɄQJL;4)X lCȏH ]xBYsӎp<K]afKPj\Q2`Q'Z]$_C0pr7! s$0S-TeBK%%,V](qk4:G &Z^?xt)ŴQD:C`yå9jDX&3AcZk` ˞Z_}/64 +Ro5 &#bkL *7 +?nf%[a&ٽl@fh %:`m*=F-,jNMK~t(ßPM oCuF3*S_jɄQJL;4)X lCȏH ]xBYsӎp<K]afKPj\Q2`Q'Z]$_C0pr7! s$0S-TeBK%%,V](qk4:G &Z^?xt)ŴQD:C`yå9jDX&3AcZk` ˞Z_}/64 STq3VL%a&9HeYY#I?UOޥV_:S͕'äI]W)w3qg*A%G5w dEqnLgqh):ak*?tlgAxd {z ί:jp!x_s &edpk:Zd#2d1}>>v2cRGtd$zJE-:]HSbe :HiVL㚝xv3f`_:>xdYсp?8 E€0 HϠ6>4!mR=R< G@?![d9C].aT{#(焌dt>0{5$hC5_s/Dթݲck,9&ȃ6{hI?:rRT η9s@OcDƲ ?se({8HA0]ӧX RKD:1]{0f?1kz, (E8a{ .?badD"vN/< u$M@v^GgD~X/`Mpnmט4D96,g P\R/g*C!qȐD=c* ɤ(NF[Xred@r12.pu*lgs 5kƺ,v=nݦ()8K(RTg @@ -3503,7 +3503,7 @@ S) =37.Y [ !tĮE\hGY<2Q@o}L??`{]5 -(K|mtq⫣/b ~CAWo/R(B =˒8Fu7  fE&6C =Kxzo$yC!kb30%I.kr 'nhaJz ZDmdlYj-kڍ6bdٲȖuTk7 kAUe `x^T1[Pj^# m󇩯 sڍj9$ȬX'z?fxw *[n׵e?֫gצt5ֈrI~b냇  4u^!1qaA!B!aQAPИА؈Jq!a1AFFU  h|96$.""Z92$.:嘐(帐h˱a!11a!Q?6a~9,4$<<\ؘ8ߎ ;<2$4N-7 w}(ג2dxtHxtoGǪcã՗#B#C]_ӥS_Pν:GD_ _P]pUhWՏՋ^Ȑ؈p1qѮ5 5q\ld[DŅG/GD/\\HlTrTDHdl{;**$.2Q8V?%<ҵx1oGƩ:_ 0O-HºW)6$2.< Q_uMH𐸰ȰQ5&11aĵ%YFQS!qz,Ֆꮠ8 Fp/FDĆƹq!j_Vs-^X/EFzH}ޏEG05z72FM{Qaj$Ì]Y\Y0_. מ/.lWdHtDlUI~{'[5y#z_k{dͽAQjArm#jm~Y#"N7G0Q &Joa!j"ҿN61-ck\0DžIL퐩 a!32*#fK.eUnzY簠CSI}1zNF#.B_P_HxQeKhZ3jS2^ 77WןouZ z=֯G;6 zHZYUA :]%UX~HPu}U?o#w R=Hӯ#ou} 5GJn@? IAI?I8Cz> MXű ۺp6{g5V^$/IOɓ6->_g9?{hNztJ9&=c̟I)Bz[szvҽ&=S̟oIvEϤt詳I;}"-vDϤtOsI;ҽ!.vCdtsI  /vBdtsI ]} 1vAdtsI @@ -3604,7 +3604,7 @@ KEEUm \h4ql>̳ԄG<]Y77512iV^Q7`HSe>w~ ˤq#pGnm)yb䊰#`~_rvbn 觏Ikd sҒC|o;]v)ccMN1|;>E%e>+(.k|*X4Ņn֦G7,&Fo$=Ȟhz&"߾MA};.ھ4Vd)5=Y~Qiͭ/_|wH> r|$~*->KHRHm7s^ F}K;?Jeucn@,-)wiinhr3RGG  z$=3'n;ƚBFts3CTp^:풤"*k+X@o&WB̚'a>SVnanvQh /[7!h*K1QĊy,aRQUSWMEC=>"`L}/vIRޡiee ջ7Qo+΁!.,+[\vIz@ d.2MP aSZ\,;+#iړ')))O=M~ )-G54='@!n;#*͸D͇Ys֖b566557cD^ItH)PpDe-y|͍`&E;肋-Ijc+G =96ՍrE%0cxΗZ]qrŌ\ ԞpdL@C0kW^|A_W"FL>a-*ғcKZJ @@ -3728,13 +3728,13 @@ endstream endobj 337 0 obj <>stream Ѿ^,)=Ϯtwo~snֱߑwt7+}Ӎ^ʔ˽zN&4tԫ.~AO({]+鲳'wy/q;6vs9\lm ۸Mnov{=^5gY8g;φl:[mζggggy{_?G{r_^5cqq=nx6S3?iifNgtn::uzZכ/gŦٴ6f،il|zg]S{wD<|kǖ&)q븉xㅬbB).iHc:mV6yumХnnݦw|~};:u2[ qHC704Ͱ58.fl01/..:k+F\\\\E'W+jj.yĶrnRqS-SNx6[c]-?K!vL$C36Sn6LBo WlS۵Kڡ[!ݴBc3{{Q\)Ȏ #B 9\9}-/vluIϮaq/1JϸM@n!r% m[VĖă=~!-۶Oxuz^CF^S~mjc묺^OYnyM0nq. ThQĉ!&mwvx2i1ͭ6];h*m޴O.NUǴ}7=چv[6 I#lm ٛZi{=sknX"YC:/14Ӗ-q}lef aZuÉp8/ڝ]dUleah#0u6cD|{zCj6{u0E>xE^[[lyeZA!W %ፈU&^c^=WWE,"F/&.k/}xx:^WKz$7WhD;ih+b?#!_"HO.[{ǚO h%"l -->`+Fy{} RM#\ *43 ԉ,C#\䤭LZ0ԋ;ᘍps2kFzR's <"me"""YMs@kFas$1l 3L$v Žo:Ve] Гp ,%,!@>[dNmVXs*dG)yBKzԿ{i{# =nk:{ﻤG;`#j[թiZن¹Ⱥ×(],:LG5= z6m>,0{>ySߌt&9fXQoɼ<%8^_5=#鱫;Ws?c`YX\fvdp= - =N|װsB;eGwi*,6 .>lMn~-Q +->`+Fy{} RM#\ *43 ԉ,C#\䤭LZ0ԋ;ᘍps2kFzR's <"me"""YMs@kFas$1l 3L$v Žo:Ve] Гp ,%,!@>[dNmVXs*dG)yBKzԿ{i{# =nk:{ﻤG;`#j[թiZن¹Ⱥ×(],:LG5= z6m>,0{>ySߌt&9fXQoɼ<%8^_5=#鱫;Ws?c`YX\fvdp= + =N|װsB;eGwi*,6 .>lMn~-Q Ml紣l)\|N9]i˜)ېŃ)Sh[P.>pKP9"x"\A"9#xr-HpI+-/1gQP28vT)G-ܐ*/=ȵ2e:PWMP7y+rXᱲvعh2=ϻv{Nj^ m$##]vf3&|2g=#eю'Άr& ɑdGbCzv>B R`Ja"m>F3p-C/>?G3rWU6…_̯_(cfܟy~ON|f,3rkvcxtcZ/5m凸s-׃FDC^}645eTِz4YUb1KWtl}}0̈́97c0d.l{>!jDZx ,ztsANS wFFdvi`h4ҜU3o2*~f O8 a&K@y* {2ȿ4MHݯ=OV~5)9WCގCtCbPލ~ jq= W tٙ.q]w"ؚNx£/Crz[Sұ Q[{CHNHWD~C6;I!_d}UY]݁=]݂#]݃kNwܞ%ctԼv5'51jSPmqcX 8nR ujdڛ}!6f o60gb*p.JՕ+vq5GkA)f|"fظZLnuSp.,ŵ"̈́4yK̈́n&A|L$@z.g:dZːc0S:JS Si)]ssQE YKEwf$s;yKO(Uw3>38=o=P瑂S7 10~ >5$CoWm\".w0,=N,mťRzq)^\J/.s|_\J/.QJ>ʥ@ۏOx{sю̫r#8ÊC\b-Hsm];yQ33f]3Ņ6ʕгʥv!M܈ֺآgv7sZug`"gc|-2 Lam+n 2wm} &IiA +ftꜬ1R d`Bd +4&h,KR=Dx*W(GcFŻ>uSp.,ŵ"̈́4yK̈́n&A|L$@z.g:dZːc0S:JS Si)]ssQE YKEwf$s;yKO(Uw3>38=o=P瑂S7 10~ >5$CoWm\".w0,=N,mťRzq)^\J/.s|_\J/.QJ>ʥ@ۏOx{sю̫r#8ÊC\b-Hsm];yQ33f]3Ņ6ʕгʥv!M܈ֺآgv7sZug`"gc|-2 Lam+n 2wm} &IiA AAT,`FcL'qj0-45íi3P$=zy36XRXh ӭ35‹mڏyzڏrg𿺋4v'SLi~h؝z9H!{ }IJsTǐw>O6YMMVLVaf7յ=zJ_C"_-dNװ3'=;׾?=צ(;p~%~O |Nߟ~^*#W1ssے4gi 7zM;F/]krP @@ -3778,7 +3778,7 @@ oO MQ jqېq\5s\wOS6*\|Pc}4twwmL'ie'esbF2mfY̯||!(TˌGU5#SY~C-bV2o93|Mi5CosIp\}`v{~ 8f,,̉cEqҨG1_q+gEjʭNܪZ=:utOdͽN"ۍke'n63 m]c}/3;sS{ " Ӎq} PtvFn7jow]00Dy2QS6@x)r9h9rJC̝Bg#̽TBsC-$GsW5Z +:tKP`d?aFۆ%',Uf!\HYY2jӸ)c1<86,-xz(AЈ@5Z ;lސη`_`Wued-K!”jLJ*3b846gTS)C1웲N ZNS6Q~|Co9RooeGRϔ,; LIrPO/BFtzK4ͷ?N%}:D8A )b֠8b0ąx'R<L!Fh>Ze82EK-0LɣjgvmLhY{2GasH̵WKc66,ÔJGBBF>LKl!q9)+'c!ߓsmk*@ (B(c32~(YX 12e#Ve f,Aq0;ʗ;Jgo͛Gѿm}JY/D9vXYT dGAz ,xb$:4:m&KL9Eschb= LSNCCBe 6ĺ4hb?jhb,hb=gQ q41$ĀM 1D^v81FZo|'8Yma6'F,(A2/NlMl/O_ޛywЯ^! Kl %&śx=;@!WW7";fwwh4]XԌV3XmaS#2R?C]U9BnN_ɐkm/ˮxZNg=8]E9,$g2 ٗF #("Gu# @@ -3821,7 +3821,7 @@ a %JNz7Ad90R Xu])&$/4P@&|nc]3W8q֠\kT J(QS5YR(^C33k BT]JCϔ̆Sjƞzxf0ssTLQ>uɪLٶ2We(+ kfuۍ|[_O]G RoQ`jHQd^k jTChlirP"Ctz j3^FVJ+0qK_vS;Uz%Z .nGgj1>߀WhMݬr'&bN$8ha,ὃAqu2#x;W/  R KLZ?UuBf8#!"*0^V8[ JLmJgF[+U 'd2/TMbgg]{Ic@rwUM(!rl369EQпϾuZCs'@8z{j:Z."cЈ2&Ը2O?HaR.J)%eQFmvXq*,𕖫.˳>Zx'ϸ|@$ -Aur4=S$./?E .a#حY2UVӻ?G$ʓIb9q* C$$ +Aur4=S$./?E .a#حY2UVӻ?G$ʓIb9q* C$$ +3yE[2MfpάOaҼ6,f4跟D3u87ꥢXgU=;vh[vx۽4|[%+|#OEu1M9dHK&h6̃mNK$qv%+ |NZZt¬Kq]nQ1NFz-WOp5 | yj]*LPx皋RJf˨pC̀9[嗐Oh|ZyrWZ4?%]ˡC18*Lsʇ[ yM2^{|hjoWC^s%|5dn݀b݀b݀žtf}vf}vf}   000kK`6gK`4gG`v׎@v>찯a?mz!0;!0;!0;kC`؏h c_>>ž6f}6F}>aX#6=vЭN"tތ"t+Aa[% z6DH8yEV1@5ŴԠV>2"tKXЭWwg{z@CcפBLqPTTB8Sn=l*tk`S.,kP'0t3H/QN!:(~m{:|=F?I}`R]fl|#9In|yн Fe2tM岾 ~Xi5o8SFWWC+ƫUjtx]9\FWR+jHt*5bJRVѕPjtjth5Db"]e"]1Z JWLWVC+FUFP2ɣJ94XHֻJW굚2]j7ez"Vgۭ UӯK;^̯>ױX)Ph~-J - @@ -3874,7 +3874,7 @@ P_" ETGCE)ϩx\MW` u̿+8!/! Ncq4, P@ZIأ='@_El=}8}Ϫng7LHAވ#3x]+ jms*WU_*0@TL>cܧҨ -'BNU%ǷM`2/ 1??4s'FG;)[:%쁤4uH3VV[*S05Gnˑɠ՛\(-[1p- dDXѴ7 Pi!U %9zbYGL I$*mf\݋l8˞}zyGI}Hёh2Kd/ƊBv\ adM7W [ܧD5Q2Z[>!f5PO,ai3Bl +'BNU%ǷM`2/ 1??4s'FG;)[:%쁤4uH3VV[*S05Gnˑɠ՛\(-[1p- dDXѴ7 Pi!U %9zbYGL I$*mf\݋l8˞}zyGI}Hёh2Kd/ƊBv\ adM7W [ܧD5Q2Z[>!f5PO,ai3Bl aQf؍So`,K&2ޓݾF+G \BԷyC5|pbn~=Aݗ?{2d$i Q{IncCeo;žc᯲+L(4F>}dMQ=QۼHtiQ-az"(6˧gp[ĺRn8W oʾ4XE\7;{/,&FD?3Rr`UI_E {V^]e=efTHGU鶵U36#&+~f4F:̒5bULibihTHӻbz4{{\u6!9WRc2gUJyO[~RnUU t0Xz3UbG[6!TX~*'mG>W1*w}49Ö5w@jF- -s6GCcyxoJ U* a˫l/Ngz4QXgjpqZE{iJj|hְ.3/, @@ -4002,7 +4002,7 @@ w! K5gǗ­2J)|D vZ`Crv$/uũ.ߨr ;M_!u  roe-.NЊɅ#׏c,KVC:']fWfC'[oG/BVim^0 !20HY@>;p!T@AR3 z+)D S2u7]eˣkۘT:_11Z+-n3P, SswT 4j UR?eܡ?*eS ,=|]D|Z" m1Ɏ*WMPa Ǟ3 %H hkRp=\ފw9iGN%dhc]MB kEL |ܴԿ?&_*]Qt~ok԰FIzTDz9jwaz}X0% ̈́ND9EHj`X%8mf 29aPM0׫} ,?T 9ОɄA!>C~4EW%Gl]$!6)g'l!E{Iܔ0 Z^8z}aPOyܠ²cg .9 .*9a|}~/3/SV*ߘj-|?/Ȍ2Y)9g<~2_P蚽&_PGU1X[dϬ=k[u}Hq _\z{S؂ ege&\r4M4X &_0A{ɂHUy†\T5ZPcH A>Aׁ]*B#( ' ͩGY/ʃzYA)ԷS#X*$$#Xd>vNKʷGP!6p:B$jD=l }uxrd~quu(Di@kȁ Jr/n @@ -4098,7 +4098,7 @@ C *6 I2PWNG3Y:e6~0z,_4ٖ TC|ϱM>l>!<{a'd"ϖfpfzE{pEivHby;9>'K4b&SnrYA'Djq0 -]ޠ8B,p㣢iʌ }"n%4R%<ן!V{*D*u&}elo`N*j{x +]ޠ8B,p㣢iʌ }"n%4R%<ן!V{*D*u&}elo`N*j{x =+\۶@/@D3H\ cIIKEԄLVrk}dݓPLC7¢y\,#U=Su ip@) !J{뤊q ׻F LpkMuX;?jXaϕG<ο(maN zQ$>wĶ>HӖ6:7il[.S k#{j&PQ{$f%晠 nn8gM7:֊Q0wN7}լV-Yp¶*)e7ؔhZzߣDY\J>ꛡUnm?Bح!9 {gʿ 'a+Z՝rfܬlB @@ -4129,7 +4129,7 @@ d t"_X`ׯfS-A{k{*~c!hӦ)e%-;l?i5z lxW[+TpȃEXA8\ʬZ Ղ RPS\ZD,LmPUt49ԜĵzҭȱSI)vW^r0f'A)wO +j97(U x & 2 S5d 7`\ܠ78j 3uN`Y 9 1gJSI)vP ,JH e'o06N^eReN9ŒˠrIߕ:|2RphꠔmKAɌ:rmǤ0A݃:4 `!g^~>Z Ղ RPS\ZD,LmPUt49ԜĵzҭȱSI)vW^r0f'A)wO ѫlCJ`QO ? ;X4/ Tvp;Oi8RHyڤL|o"}R@Cd0iȳs[}AdžJr$C8(ueeQvgvA)ArA%AԉEFS`ml P8(wVT~{ș,失ψ8kP}W E}lA4@# +9CH:eP~NƠV}qdĠጾATJ϶0xnI%TnU#Ƨ  KP%K u1OIPjA ϭqi%?@(&HE.% RʎzAr 9 MN9l#rT{B GpuwY&|l,4FҦIJL1c &FPbJ&[f`Q썌`80(Z 3G>4 ^Nd5 `!# #hgAX="3S-oAq?(psP#(cڔPZft#ˠvΚH`w -€dr&E2(ZD "iԪLdvНB(tn[ɯbcqA`Mm$d}20 |_GY7,"'Mͽ#YWtvzCJ e HBye_ |&i$PC? $T1Ybxro@Y'8PF?N ({a@/`3Pg?'2EZy>]&b]m1,f58/ek/P~~;^_jݘrUk %(}x!Ѽ(F>qyw@)0EL ,V{Mb ][mW[}#^>m",#A {)5}$u#'C&Y/}(K8OZI\A}|p$_Zٿ[bU$+o*RB<}{+f݆9,Vv*8݇;߰lrQ>)2O=ө r8(ܷ+oAS[rO>X O܀ [H>.,>='C܇}(+O܇}@DOܧ*0* KB*r՜,yw*]W4}ρL4}lOP*0$rܚh )-Z%r<>* r_x#!zMr?hmN>ŇP;:E p#SVuqlYf{>ѯ?ˣȮͬ>d OJ }w&m O }E>+JN,p|I+늟4op~)3&ff;8}fL>P>VХ]ۇ_G)I'O zܾBW*_ۧ Px}SX}C h ˖+Og 4>!>Lb[J>d؇+"}RfUʄlh>ZgW b  bTR";OYOJ^ &Ɔԙ}SH̾Ev P-;O?%K`@pr۴ʴ7"`]>OgL mcc6}Dek!G1$ρBu^2> @<v.DX'G5 rs ܧ5cv8} >K( fY'oL8Cކ3K(vI#owO"QTQԧ(P p:~?addpp#O[&{g]<#96ox4kR<|֞Ĉ붥r"B=6 - ,`W(_hS! ]O+R<9y|2Cx|S}ES4ZƧFZ,>%dw>@|w >o> + ,`W(_hS! ]O+R<9y|2Cx|S}ES4ZƧFZ,>%dw>@|w >o> "G'|[YowBùG )|Oy 'kP+zt[6S[1ԽwY '^9 5<po* gb9SGOճ%ޞvW5 &m.W w4{P/c]_ISbD=ma;uYת'){7V| BBM`OXj_O`ۯDC ד<l=oh=z[BMձztfY M-UPs3'] SaՓSw[J*5z,NY.&H~ʁ+i&@!&Գ~N8SO4 ;Q@TzCV@ʋvδglGo5.‰z*߈z&mL9CFΎJD=fC!$1@oB+'{o@=XAP zFG+PO qGg"AK @@ -4153,7 +4153,7 @@ r_ C6}\߶bLPBןL^V\"M%AL,z$RAPrWdoP=)֎ۡzTO'NCvP QMaQt/q ae 1z$WɏzLt*z}ӐzSHHE$%e;)OAOB[slTZԓۘBԓ|G7g2`K\G̞oIR, z:;ztS侶gq~<vV<"uc;cclXnH=Ӟm$vm !Q- םz#̩]$=oovAZ~_I/iǡP MkA!T{=i[fz!h]=qh nC/z:a6ӡ'շݸXMͺ)1e/ntjoKW9ЫsyCA*|CaflS+Dwv׷;'5Nq̱|h1t:۩tk{2zGm+%& +0wH(d#'%Pϔ{GGC T yӆPϺ[#QE$%QC4=JjH,=u (󬃏>m$vm !Q- םz#̩]$=oovAZ~_I/iǡP MkA!T{=i[fz!h]=qh nC/z:a6ӡ'շݸXMͺ)1e/ntjoKW9ЫsyCA*|CaflS+Dwv׷;'5Nq̱|h1t:۩tk{2zGm+%& y9k>?[εU΍綞AiwI:L|]뱏J~js}^-v| l VgM^smC'[}Nµt)fA-Ý^ML{8@5>dQعW76A%[Ntx)?&z/yvNﶶۦg\kNwT{rKwoJ3 ݿ#zΣ휯;Ž|tf9S6]_m1 57ňsw댫QK拍;6Z۷s[Lj!\cRWMG'>41<ݺO6Monn~@e BO1ҫ6>3iOW˝!z-Ύ1ʫ+]i6,-n 0G>ƻIpy~OEλY>S]x6nϧk\><7֕k;ќN/sϵ8qz a.O@|<~7!^/D5vPg$>0Nk}]=F2j~  ﶫ(AL⸶Vۼ0Rc<=nr{=/ 9o~+6>!]:p3zbV]x8o"'Su%v|mAXٌC"!3Ķ=&{(eXj+c٭vOUI_秾}脉^ WRs|5K nknLboN3 @@ -4172,7 +4172,7 @@ u') ܳkf0RaRTM!=Vsjj79M!$ɉaz rbᕊ6Wَ5u^^w**kp&G7ҜIoBhк .ʤQn g+R7*ׇX^18^wZ5#:.fN čE\M1V8TI\PHbͅZ4ƉF5U>,^ -sIuǘ=#iPJzw -g&eeCSSu>mzVFS*8=YWSOSUq1feǜ^ '3sP'f%+awa}(1$'TPGXX+2aI5n!7.[Tۇ%YzSld ];@5K=cMDG'bi],Ac<Z,e5jf~٪QLVcO _N 4?!#jM|a+~uOINci|Jr)ض Qأ#EƪO\JRN.YRh@;mG]TuDn$rGԢ:vqnf.v[$ߘ^3P -?Qٶ^!L\[/sk8rA\G<-$:>L.D20' kpz fk +?Qٶ^!L\[/sk8rA\G<-$:>L.D20' kpz fk ksKim"GiVc܅5 ̣4"O+kɷY dM |\(-vY3 0з(JSh ߱.qI[Gα[C2s٧mo5?Vp|WTN]qg[k<@m\hvՈEe"`$?PN r{lGgThWʰ]B Rk6~TmykLtp1TM3JѥaxFÚRr7>25O6;OjYVæcq]ȳ<kTUa%3r0ޯih|PoqnY1wkx[4wuġbZeok@/kuMUVMq-Iȶ,!tk}[$W 4j"Ք)_@@ow=!Q%$BY9 i!@8 kO =(bcMsR,\2xAY\{6u:hChdu!6[ !je#}FUsȫZc r]#}X϶[wo@ui´ (E/F&kوڮ*'u>iɰU`U-je'CPx5Tz'mS]C1Pذ>)&pR}t~kh.G2j(G~~`h2t+gy%-n jd½fe/>NEWq: 1CMڈcR4Bqe`@UriNK`_1v#x& #g0 :w7h{)_c+%KGDzkShUMGODy27 o" 7rw虃xSE{i~vEh0R\WelЃOtT;bHvR2hRل;69rBǕ"kDyUUI@LWw %3Fkʰphg8ˉQ(a)JM!w J M F^̎eǮưi]_RȜ.MKq](qRHF.&kJ,N @@ -4192,10 +4192,10 @@ Y4 +HMBCi Tq>5U|$M5Z&9 N bPDY6INs_:e%JCs\9k~u 4= [Z7C51 T&B-Ek&զD4 %M,RjR ΋A|HU35CɞGoɍgPCᣐaAfmQy*JXA ^/jPQu -j>"JDd =$im5#X㰝Ϛ#N]ZB#N^4>!y!]-'{/Rp]*<rQiMRʵRWVy&q c~X-B'I;*FX '诖Vˍ}R@0: :L IҊdkaj \kҚ?T^BjsJU,ZI(O2 +j>"JDd =$im5#X㰝Ϛ#N]ZB#N^4>!y!]-'{/Rp]*<rQiMRʵRWVy&q c~X-B'I;*FX '诖Vˍ}R@0: :L IҊdkaj \kҚ?T^BjsJU,ZI(O2 \H@l4 JX BD!EFWQ5Y`GHX }8|͇ɍ)٘DZm[)V:IE}Q˜66JZL;pdaښ#Pۋ]aO*k7Y5Hdiۚ7%aSW-duH-,J7A]+ TV"xOieKS%=QPXhC_8S+W 3TLJp$Bn,DFPo\gW#><3,FcL$x|A1\DHNLvd?Ǡg*RҮo:zxY:mEsrɊ6u52i_&./+S#(Y[\#җ[-+s|*Δ8ti7DO!x'kqZ驣ؑ]pp,%3fT1_XLlJ6#w/r!|pl={f\Yx֘Pb |LzAn?$D8Ōqv|Y*̥|}Fd*T6K[riC9%VʸP`Xcy47 S9SvzP !gJhIǔ5Vu99*~ ((k(Fq=mV=V\D-r,KÆX.C*haؐ]Ct7P,фB怎DmKtOAw0BN;-,ΰBMbe"_΁`\EGOBh˝AYmĿLIE$lhs!~|8FPbK$ H ZC+C13A9lLI!KN@YhFf^,iy#\/r#i=7ƻ%^|ʂq#M`Dž8c!foۺ]pNiqrdI6\pB0#rEjR {4̅ผV YB/V˝aDAVK!vo"c.9_ ᠾ|k `dM#0K"& *AWDʶg/$9x;R-#֓ +N/g1r0eGLuc,4.r'}̠e%. zN 8Xǐ蘰2vLϼ')UBΦn(ck + n;YOJJ~/`#a~*% ܔ('M6ld|pN&`3bαuˍZnlłn>7P,фB怎DmKtOAw0BN;-,ΰBMbe"_΁`\EGOBh˝AYmĿLIE$lhs!~|8FPbK$ H ZC+C13A9lLI!KN@YhFf^,iy#\/r#i=7ƻ%^|ʂq#M`Dž8c!foۺ]pNiqrdI6\pB0#rEjR {4̅ผV YB/V˝aDAVK!vo"c.9_ ᠾ|k `dM#0K"& *AWDʶg/$9x;R-#֓ G$L39@)6FIf%|]ζ!5,fӜcl Z-ѶBi u!VPre\=NțG٤ a`pa.tYc9k/n,6 0EyH+,r DJ @R.cc]vwloIE@Ŵ#K tmeja>V. !y`P~̊?k׎4~S1K) eFXviZbT?Lc1jfK>g|H:\\&O*" n;u|\NS6SPp!5 *$:Z s'.-QȜ[ۓĮ2ݯ H#_<rҨvD6'~^O~l!m!6_  Ց_ IGBC58f]UYO @@ -4238,7 +4238,7 @@ N!?A Rbs-yO2*Cxc9nnj#bw} upO{)>:1ð|R2.I Bᤇ 1 $33rW) ^ FE! dX|{9sx+g^ {z.Q$HiT}@N7bǗ1$2/H8Z˅7)]pͲ`1oV1o#=6'RhPADIh;bOxK^!ITsp]L Mb'o`DрmzcH#'v~<x7øz&ӣ+)(ln\ؖQȞ"ss#kq^xhօCKa $l߿4/nP.ȘT) -/9a*w=vʅM`*GEk 8UP}0,*Mkh 9'xJTWfZAQΉO4/O5<]}ׁN/sorL_վx~` g -@D3>7#=dlܣ:hp2b}U3Ruxd1at禨uR˪UKAGlZi]F෩`z?O̅/ bVڽ; Oߊz'_yyd{t۱滈>>b׈o[ +@D3>7#=dlܣ:hp2b}U3Ruxd1at禨uR˪UKAGlZi]F෩`z?O̅/ bVڽ; Oߊz'_yyd{t۱滈>>b׈o[ 8 \`7LU$nNae* `PjDs!%('T *Nct*V-S>uv]:Љ8%qImW4PZM{úr?V[˼0:.SۑYLV{罼"^AeW9!Afx\r]-A?Xʇh2GR0Kss8*JCD7bd?J/0qYΫY<׮fE ͢햇9Q^5Y¾%}.>ꨭ7,< | WоrzP! anYU}RYB`Y*Mv.?e*~Y(%o~^S{PL~7-P0523,7+ē+g{﹍sN`|vmEm\C =q_DrMp;ePR?'ua J_b4kg[782_1PPg}V۫@nM7?)mJEzعX ̭k=wUMgB9WC-DbKijűoՏ[XJKij~g i';l <˱> 2>+" N$+pۓYVxh gP-78Nq~&(Ycބ~pEzLܞ^-j㰖Rm h7&F{KDg =Ix*^[U0;LrAEhߕtE7P9z"u9a{ӒuŝgOxW4(IA%SQ>$)l* ]+|_ @@ -4690,7 +4690,7 @@ UlS@k VKs2B VjNg @RժFIvHxDmwE a/L$E0Wq'9);~R.T<ʍ\>8 Œ3n0"Pa`&)C2[c".Ur/UԬ(ۨy97ydϧ 4{ofh{5.Dcv%GЎ&xf1K% !q% H{,n$Еlz1:ԂI֍h k\3YP|j0q8x7P9l AS':e ɂdQ}KM:Hq.zr Xr̠㧻QCGwl5jJ AViM: ~ޅ}1d"ǵ\f3?,j~,#6Ҭɧ$;R=N.?C;BVeX1t7ӕR\Db_䪥ypjGg?L{3tq 6 -fB"]SBꗥRkwMXB|B_/.ks.jd0~bQXKQ(fG*^u\ju'39oc)_p)x*Yh +fB"]SBꗥRkwMXB|B_/.ks.jd0~bQXKQ(fG*^u\ju'39oc)_p)x*Yh PV)-j(+mP#ͺzgݑS"H%~&Bј3LwޒK'EFq}`B>qR4 e7EJV1Y,G| Ks5#i}ALo'c2mxYmrhsV7ՐBXhk,,䂕ȟ[D'׍p n" BgQL3(.̺a[+L cAGKN`% ARY@M혌:ꛕ6Q;tOjth_`tZ @@ -4753,7 +4753,7 @@ w ^f`2i: 0y8jJuxN#_$F`w`pT_o|fЋZʘ6iRli#q\1:yQK>qKzfwl9f\۹|9Іi81р" }s*۾6M?km<7z%Zl̶isz1UڻlSﱯW5%7>DF~Kw}oKj'Yu.CT$zE;{1RJ􎫉 +M"U=%֬z{ - ^4(jOb#"6>$zE;{1RJ􎫉 doHU/,ѳh̪[OB3QV&6?GP4D*_y}!L^}a\2(DWHtf"vO^D,YPW/ FP]j޷RSx໺;{KC޷$ͭĚ\4럈` nBdD:(ANg&HDtɸOD7)>zR3zLͣz|ڵ9{{Ŀ2m1R4dJX |ʀC\/2ݛe ㅁ5O;n\5:ȩ٨P#r3l3?K۩>w#R*ǿHB= a W+@ N+ި١_~EOY 71k+7>rVҥ^N;nQksO{^6$+X~?;hb>+Ƭ-_<K|%q@ӂ7,ky|'#xLifkcrX}> zOfv[ytʟx4> tA_Dj0L~;@1vt ^R}T$X Ⱥw{d.gerc*ݤ6Ik37t<31" ɵ(pSsoܱ0Xwv\rsn6>Z7tס=+ak#:#h~ P:ݏ R 8C1`|}{{,\nȶCp"m)v$AF<*1X=R_H6w@9{Pc-GtX\ O~Q}wNU2|qI< tn:Zaejky]} ̧N6HP\"==$Ġܺ7^6db,U˼bdAݖnJODҠ pb^Ur ϲj/ 774o&OJ{tզaao3E{H  ^mw~+c ӊ杓/`.4┴Ͱhp2Qp6Pw>)OQ=EE[;Ic&~& f m>If(+D @uQu܅ZxxY^ĵ~ZpllYJ)cs~~^³=0Y>7>Vw"ר(bHvaGÝOG F=%,Bn6>8H̍mϞd\17&J';Uw%@Z< Y9DkaNBMREd47FRz[@ξhibR1Y)V?H [d@𡾯na`vrEPJ-X{ʛ{jY,TsӼ,t]ļ78V TFu}δgOuݺ'@Y&`4LtulxZ`zUud7z7?rfMUKU.T45hNleU)cz+3L#` \.Gi*WX3tW(P?Q$zk+,{(9u-N^Sg,/kHEd0۩ KL`X g]8I '(ܵ^냜8G6OFlz9)\XʞpA|Un/KuJ?D2MɬMfw4U*cevgtqXdt2YK6&1~CͤHԟHt}wm9͜;X\zem7YQ[OXoPIk i[9]iQ?LFfV*r;`щ__lylCZ^ ~0D+u)DҴ t{w48/z} -5\ZZEkёxFBI-|z 1߻2W:N`Rpwuz5C;*Y\RCvf_Y~)~)LO +e\:&#%΄KJX8fs)dхޔzyӤx4_O7k7/?N%o}2:hw( YmA +5\ZZEkёxFBI-|z 1߻2W:N`Rpwuz5C;*Y\RCvf_Y~)~)LO +e\:&#%΄KJX8fs)dхޔzyӤx4_O7k7/?N%o}2:hw( YmA i_.&Tj=E^ҭ$fI"{M8zMoB Fw%va[}Q "d&(̀yS%@499O'=7fEOzp'3;vQGA&#tn廽\O iezaR#?&KYug/ea95Ɋ)'EF+bSe282?kPZ~ԽuJ}ޓH3r8qLvndnAoD צDq^Oz뱾M Ӵ̼sWbqp{Wg\u7Cj4M~g.?9kqA5~cH>ݬ ڝѧʶ9RZڴ&x=jy  *8ޯ8Ҳnbw Vl O\ckwᨍ~qKՙm1pG% .YϭMhR+IzQwufʷl:zQT<ʄ49D܅DD57}Uv_ Vj ;]ɦ6q.QwQh\ǭƌY@]vT}}rT~ɏ6j"|ɽUa3mm754`;apafhlF۹JwVھM >iϖ>8FI'iza%`_KEh{+hæ<1=y[˖$O6'1\?-4L/AخS gȳ^^wOQ;yW(7 36c'r?*8CݥD09t=]uڴDS. @@ -4871,7 +4871,7 @@ K: 5J;rOZ PwuBu썢_ncm9ܾ5 k6){H5sO{ /xȏoJ{\Ã'B 6w4 *qA_c7ؤѦ׍l2ַpUv5 @ߝr3V[8xy{¹[ys+44)[P2qfalHSgPX{̌#ԞQ;ūF<'5K>+⺜qɝjmZ\DBfXq|a!zqIA?ݍIqWW~f;[u򫢬tKր?U uhB~A o4"fu2V7tj˝9ϫ:ߦQ\ʝr ~z*pEM` M^aSWPʸ2!ܵg#;]UjN$M^]CV~j> P -rkn5ifK޼|>8qupzrFȜ>ȢT$REAf)!?\`2_9),왶\{|"=L5eQ0y~\LRlYi%ܛ=f/"N @O̸l8)H&U`rU2NZ'K{`bCmPIf y#b6_]/wr hn,Fv.)ر .(Ӡw{b +rkn5ifK޼|>8qupzrFȜ>ȢT$REAf)!?\`2_9),왶\{|"=L5eQ0y~\LRlYi%ܛ=f/"N @O̸l8)H&U`rU2NZ'K{`bCmPIf y#b6_]/wr hn,Fv.)ر .(Ӡw{b ! E^Vpo_4IE-IHJ$P3$!Q܎RƬĒ,+V WxOtZ{<sս:{S>uk.D_J]=ݨI$I 5(Msɢ2FNp6|љyڇ4у;?{ &w.iٽ)iask94$.׮˼.ViVr<ٝSE<᧫/EY闇~MNvU$ )gϒep;֚G^t wLH\[ :OWq̛ҁ~R>f\Np[8$+eV?2{P+ YWuE]ҹ @@ -4960,7 +4960,7 @@ Hk W)>SᏞ~  IqGx}G|C[>z^|Jt{t7سAvnYcHrQa|Y:l'ዴwlOZan8u?\M1z<t9)bSdݧ2^JwvW}mln-M+*^7[YRKv sJ5{O=_kԦRωQ+nV=h~r;^xoW}>:87Gk7prU[ī>;-eo`sTlSʇ3bOcOæu@G;{׽}_n m9ɮnլ|ցŅy|ΑŏʟS3B'`ei\u4=l;y'vj/9P??%V?~nk>2р0X>k<_sGw:ۘw6+:v9N|GTyD)2!ÉCS Qxw}oϞGx7nRNm]lc:?hUG}ym-CRE&-tG}ώ~nh *o'KHmw_d7un*\r*w.ue9$-Vg:͉?G.1ShiӸXv 35'"}\ٞ۔fu0 sp󪓴xڷ~V{9ZFÛE*4"g4;1:z,E/>qr>qZܯŹk59˄󼦸}%  G$:I۷.]Cg5E4{8l8Z@Ѽg귴/O>65r^+>ՋF{T^~_2ŊFg}u8g;^~\Z'H W]>:ҢnhNޘm:ZY~u}*s]c\ks#OQ몬~h -rGfh^)O[gtvR!Kn:kMs&q.Z!ǦO'ۜ}ߒ_iu_[AqY^W,Uۨlnѵyl5_U1)b> ̾ji; ϵ;-  Sο[@Z 4ң*w."NNܗ\\8^VήaӞjbb4ovQ⑪ٟT-W`4;c @@ -5043,7 +5043,7 @@ H c .(f'7d;YzٚO_or'#}t\SA!ܒX9^.prv/@fXߠO6B&:’ |vHT2]z:=' g-%ϸ&Q1ƊV =G*k$VX*˻ З!X?.]>9:f$?3U`U!yApf['9Y)wΙv.e^zڱDvfc[w7qg 1=4{]JG}D.o}y,phRry"8܆{}R&PoAʊ6 XmzzCKUw~%sdh9A^;^>ҾQ€~RED;NgI||%odS/|O>_Qɋ{ %WŬ0/φ7b/8N=oCCSc uLWdyDM - -DcDDg ʗ)ҕȜy1!LÅׇ$Xcxԭ] Tf!)K 5 H"Y@ W[ k*H}I䴫VYe7 ( }QG + -DcDDg ʗ)ҕȜy1!LÅׇ$Xcxԭ] Tf!)K 5 H"Y@ W[ k*H}I䴫VYe7 ( }QG e.1'% S,+ ! OO7 ~f&S=G @=l"POHe-ԩjE\[ ծR* ᣼=ޮYih pp|枘2 |F5P hfN" @@ -5268,7 +5268,7 @@ Q Sm@)-oRfB;5B,3nNbKB\ƭ~WA{+U%lUY5dtdh4Tg}H e8vcR-$sd0wsK6JstP},5*KKC}G>- ,A_pAߪeួrFvO=&C{nk~m;Z8vٍ:A b?P)s'=-Gk6 5CG}u%X,vo`yƺW;cuEfa#a8AցAֲ7{"|6 O]>#KI2w鰸嵢)]kk,fȴGU -8#_f8X +8#_f8X g7,G S=K.MH6\nðMi: uw#wg2vԠ dlZ6xsmxM @@ -5280,7 +5280,7 @@ V zYTz( )V[;uz5,9lLAo}Kڲ滭$LktR / Wc(8ԧw\a'KC鍄=+xTy_ ->z3<+oug# qgqyx,GsU|?&6k՗m[PqlAn\wb?'WDQ4To ׬:U/\Ilkn6]&ji$|x +>z3<+oug# qgqyx,GsU|?&6k՗m[PqlAn\wb?'WDQ4To ׬:U/\Ilkn6]&ji$|x wc[o59ZcQ].Zy;'SrjGғ'ʃV&A޳\g*]ϟECfoLTqFi|p7aWo`N+paSkTsL@킃au拒KZribbP,RB'S3~TͲQN*Snf3|FaKj C6F[}nt*un1MR$m91 ^8;X@(% @ @6llcǨs KY[Dgu6<\TCQOaj&Y'yI @#߫63(;aϨa5ˤuV;_~bpO~8t))EI9qSCK㘩R?C{go7YMwT'u+D `SqqĴDKiynuI,)r#_1}Ixn۫4f#! WLKq#uɧUn׷S=eM+P[?#Kj\v)>={rVVė =D}`њ6w;%'SY_dz7B}Z~Z>Ek Vuma!؛/¿~}ٵIn'Wth_Vq; ~s=Xc.(nti?1-8jz\~}RؽZ΢oXrKW7PZRqZ徇"|ր؏3.3)=vbQ5zܺ_Y4Mz_9yڻǻ }W.@Et3+Վ夕X&?5ݤg-6nQ=-v= 4e wj09mtX >ښڌ+ޤӺBUN*U]<-πw%pK \ދ 0| ^wM=D.7E#B-])=HڒՏhsp[m%TsK5+ko-?5Geєck<(9yīMaw:#kCO~ x) `]W 78sw#,o(Oޚ.ޖ&nzRb^ӦT%r_m%܄!;pk-FX\{s~yv]2 "ilM\J[i H\q71q6h358ퟦM(kj8T7QT;n(p{d˶2:* Kys'γ{0̘U(f[9 fdaटQ5ONw- ϵ{uVObI YP?v^oG%B~%Vͼ,)[%WH;YF(IT81,}c[a[,3.`GFϹo6p"FB3?_cz 61~}Ԙ+4C,mZ;?n[-(j(D%"=ù̧.\.WkV';FS[e7@9dAiq vˍ|z832*62ƅ~U>.q@ z6Hz G3>sx3O~ӯbłohȧ^ȏ"ei@sv-W^9Hh?#Ϋ=ҧIn|ܿXW7ֲ__5uEW;.I-M!=g50/GVTc|boA:`|ɜv *jsM9xί.j"j/"rTm_IFK 0l.+2L[Uv#iĜ"Wu|sc~\J7ߟϘI0rYnzz -7By ß1y3Q{FFA?'m]ϝw/!4z3)S LhL?Z[=tY]<{G>7йI>N{n_j\/hV~NVZ襏kuPgyY`ڒ_&7bNae6et81 ߈WȖy\xN˩E +7By ß1y3Q{FFA?'m]ϝw/!4z3)S LhL?Z[=tY]<{G>7йI>N{n_j\/hV~NVZ襏kuPgyY`ڒ_&7bNae6et81 ߈WȖy\xN˩E amک 2U{Ib+C{t+}3eON*%7;1YXYf,1m3J}ض^]Iح-AC~0~eZLrrE;}2xpAU[{i;toB:B'N琥䑁V$H{.mUjs9R뭕QԨQ$_OF1EMi ,̾_2ǬT*gQ q@+bk.0m0Vo;V/c=q n1XlTWtPsUT~jf-ӂ14FwZ5 qmm"QSx_잌 X/j7mr@M ^SȺ)[PDLἍYmOgtdUAjīai[|4LD YGcy%^FGe˩rE r^ZwY'K+ vS :v7Agݕjg*DQ,abC{9i|aldT,{4++~kZ6KCg62%z݂,6=A&?(p[džf;0j+g1FQg6[g+ŋ?MeAP-+Wɷ\??*I]lrnUyH9_I(~8'Y1-BZI5y<9OskZ8-ʺ1J?XoJPc;e{~yZ+ETNH[>7y:3 @@ -5372,7 +5372,7 @@ r d#? ?tҹ2`tj~oW<8%L[fY8tm^ .? dt&h{i IkȖ5T)>kXVK#wUk. ,%-9`ec/cٌ  ճ:hle9~-~Z:5߲2jqNj{UMuYm*cXiUfo^~|MmWł4[$3ыU.亡.W) \ɣ@PH^4s:ݛz[؃{z`Re,^ EZUӻVxu}W\:@Jߞ4N8ߖ轥@;@(':n*U}w=l8g2ܒ0 `b#:ѡJ3:vBV-G9=鳨N%Wl$?)B/BTr?8Un$hY+1J883ѨhqX1=:+iŝ6S\.ޙu?<ѡ\m]&Byɭ}Q`iG j.8kuӣ<*ǠuZb.-(I@@&zH7z#?ylsAdz+f7ޚ܃G}F0˒;ER y]^?.&@+lYl'F]>4~&W[4)/xĒu#C2d%rQL3N0vG0r7ա -Mє-8ʊ]2׬1-<37Ö*Ѕzw50trR˃Fz4ȮPÖ́XFwQ(|Ӎ^zbEW#ģر\Έ.̵zP+iJGU *uri^[j(4+= >'Q;ئ5o?iL1>©zKsgGL4k6ِLar}len?{K~ ۃqSX*EEcmG>?"Uf-\pNXϰZS@abdM]0ͰU3c_Onwک͕jEH u[|y/ʓq3KL;V9YJ16ҩ%b#:c/epVFb\\(ctxp&9%E?UTiPFL+b6˯/dH`BXhFzsPuɩ2\Ԭir{}| +Mє-8ʊ]2׬1-<37Ö*Ѕzw50trR˃Fz4ȮPÖ́XFwQ(|Ӎ^zbEW#ģر\Έ.̵zP+iJGU *uri^[j(4+= >'Q;ئ5o?iL1>©zKsgGL4k6ِLar}len?{K~ ۃqSX*EEcmG>?"Uf-\pNXϰZS@abdM]0ͰU3c_Onwک͕jEH u[|y/ʓq3KL;V9YJ16ҩ%b#:c/epVFb\\(ctxp&9%E?UTiPFL+b6˯/dH`BXhFzsPuɩ2\Ԭir{}| }TP1`c;Ӕyk<c9@a64@eI*. żb C66+@(|AhVT1n0UbK8$tj6jCOT@z<|/*EzqnGɟMs^+ߋyA׻c`تl7vvOҿ.W~9Lد ߵ˷m2z5C#R{ao+BSLcľ{g:_e1p $T WR#zX&j8jrT`㏾6%+!EI#B3= Z֡ zA8];"Q@Q3~&lnH>́d;ׅy5 }6/I&2`z~ Iے+<'q1H'DM'`@F6uցC17 ? ,|(= N#@!((i{k[םaL3,/Eb|v) GK mVv؍SșH߿$!GE@5?GS(& hvG5^@:uЉ tPhOȫ۴[3P)uRۄtMۑh}IpbM$]+z&Bd>0~JSfwDA^٨Dl34r7$ٷWo[a~es_dg=D'BWN/=Dx@aع{6{X+rv:Kn$j_ WI5O¶hUdfOsR9=|v6-wJ+KqIgaO\pNe|QS~{!whlsz'3N\3_~4ԯɧ?*9 @@ -5407,7 +5407,7 @@ Id lkBqŊܒxzrjG]E{-օ'!b9tXIڀs.J>zi-ˀ#7g -|oD# M$ a˹㌞Ѐzs.ĵ~ƟWIʡS2ߚ:Y%'WQ@l .ny .k>#20z@vz@ j| :0Fvz-pd&mEeYݝ7Sqj7s4A%Roße 9`X-p\9sO^g6{SaT@%rKFQ%_%<;Д~yοcWVj;Ns{S{SI iĿ7]Q.]GNj@3 .? a m@- h]BZoat-,U.Or\~ۯ2֋: mtw}aD<=7㸽Dʭ,Ȇ.{)8oI_@C=plk%ZlWar΢!{x.GFmElw[)D8it m?.0j3 wܛQv y,b&14 %|뒽(")̮GCGYFѓo7quU+sctoby; jwtFtѮ\Zx Ӿ?w=mpǡ[&ʧѱ-hFԢD}u>tAi^~>N&FqtHxY\p7@<VGvnTMk}'n5)4:-h Q1kp]4:}B)tDO|Gv5{ՠ`*beUQ+=_oʲLBަp]X&9vӜ}AګUՉھUfj\\SR=Q׫q*Nr+ZܡL˓+C4ofzNH=܌8HjNNr@3%{R=zNmGիqU5܇EE}MgB< !S{0W""fJ{/T1 +{!E)MtZ`PS3|hܪgr=OيzZ*jK[.ddsu\keuV!EC>\~ۯ2֋: mtw}aD<=7㸽Dʭ,Ȇ.{)8oI_@C=plk%ZlWar΢!{x.GFmElw[)D8it m?.0j3 wܛQv y,b&14 %|뒽(")̮GCGYFѓo7quU+sctoby; jwtFtѮ\Zx Ӿ?w=mpǡ[&ʧѱ-hFԢD}u>tAi^~>N&FqtHxY\p7@<VGvnTMk}'n5)4:-h Q1kp]4:}B)tDO|Gv5{ՠ`*beUQ+=_oʲLBަp]X&9vӜ}AګUՉھUfj\\SR=Q׫q*Nr+ZܡL˓+C4ofzNH=܌8HjNNr@3%{R=zNmGիqU5܇EE}MgB< !S{0W""fJ{/T1 kQ 8/X>^~FWRd>ZD&8mC &w~(W_.LZ}ܒC=='?M }O[fج/S*|`w2yt2YQ,2'xf$/6FНC;&IA]Soыm}|K&8e]A#2 \xS{- KP7VeJd~9i$ \7e8ԣtWg2+E/#V>S4un`soP̲2AtU#dkFC=jI$D"W9mM>&t+#u2m=Z}ݵIvsך쳌xj)4vh_5Url[EG]~">Er_oG %E+.A}Z.JwSkltեŶZ[0gMWfMM?1}'|d7!AVUl hjp\<~D4/B wJ_gZAĞu6+nC& KF#ڿv-I 4Iւ_bQJʌؘKnˆͷ2|6Ɠs#S3*N9*lʘ${;`ʐaIfN3]gԋҏNIzz3jTBXkҦ{8hfv @@ -5439,7 +5439,7 @@ W r]s^dONrDP%;X}SP}ʦVRղRl50}\/V泖kaAyRţ2/]K /DAaZD9%=3|4i`u/)%OzMqBVn|ϗj5Ю}ӌ֤sÌT_CٔKX.n\{ν /'J2+tI]) ៤R:j jU7_t NU}>Mkhg,%ո{p] Ъ;QS6$vSْDm{z`9*4f˼ -7r%^LrDLʜ4 ].Ec؁סּ>G 0QS+PyH!_8y:D>[P9]+J=kְ,sR+fS&t +Y]C=3&fzd"Zn|mu_DS*1~_Xh5zJJs] j [?rk@=2+²$f7bDcұI*u&H$Bl7-"33᫁zw_ˋְW4`ۃ*%r%^FH"/JQ;+߷$5B^G”-і,1_q%Ne_,h0hg1Mƻpɓ؉I|G 0QS+PyH!_8y:D>[P9]+J=kְ,sR+fS&t +Y]C=3&fzd"Zn|mu_DS*1~_Xh5zJJs] j [?rk@=2+²$f7bDcұI*u&H$Bl7-"33᫁zw_ˋְW4`ۃ*%r%^FH"/JQ;+߷$5B^G”-і,1_q%Ne_,h0hg1Mƻpɓ؉I|ՔN%GBjէߜ S?9a kKW9CnS' GsNt;5~t|D|y1> endobj 345 0 obj [/ICCBased 318 0 R] endobj 302 0 obj <>stream Hl͎7 :&+(M0 ]y|TOƃx0@Y,CNoy˩O^o~|/9ki{הɵuεZKç{:͜4ҒUZe?==,>+`F`hEV&3(xIJ]4ybMYWvɅWcDfK{RI2`IM%Zw*Ѫd=o@U, -(܋w#RRIPeoF)_h.GփWNGoN}g[m diff --git a/dev-requirements.txt b/dev-requirements.txt deleted file mode 100644 index e40ce90f8..000000000 --- a/dev-requirements.txt +++ /dev/null @@ -1,17 +0,0 @@ -docutils==0.12 -flake8==2.3.0 -mccabe==0.3 -pep8==1.5.7 -pip-tools==0.3.5 -pip-review==0.4 -py==1.4.26 -pyflakes==0.8.1 -Pygments==2.0.1 -pytest==2.6.4 -redis==2.10.3 -Sphinx==1.2.3 -tox==2.4.1 -wheel==0.24.0 -testfixtures==4.1.2 --e git+https://github.com/nicolaiarocci/alabaster.git@15d190f29f86141aab202843f2bf3edfde71e56c#egg=al -sphinxcontrib-embedly diff --git a/docs/Makefile b/docs/Makefile index f78dddebd..103434533 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -5,8 +5,8 @@ SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = -#BUILDDIR = _build -BUILDDIR = ~/code/eve.docs +BUILDDIR = _build +#BUILDDIR = ~/code/eve.docs # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 diff --git a/docs/_static/eve_leaf.png b/docs/_static/eve_leaf.png new file mode 100644 index 000000000..cd01dab32 Binary files /dev/null and b/docs/_static/eve_leaf.png differ diff --git a/docs/_static/invoicetronic.svg b/docs/_static/invoicetronic.svg new file mode 100644 index 000000000..faa937c95 --- /dev/null +++ b/docs/_static/invoicetronic.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/_templates/artwork.html b/docs/_templates/artwork.html index 16db93171..c0f964043 100644 --- a/docs/_templates/artwork.html +++ b/docs/_templates/artwork.html @@ -1,4 +1,3 @@

Artwork by Kalamun © 2013

- diff --git a/docs/_templates/sidebarintro.html b/docs/_templates/sidebarintro.html index f9e2dfb3a..9650863d3 100644 --- a/docs/_templates/sidebarintro.html +++ b/docs/_templates/sidebarintro.html @@ -1,35 +1,30 @@ -

Stay Informed

-

Receive updates on new releases and upcoming projects.

- -

- -

- -

Join Mailing List.

- -

Other Projects

- -

More Nicola Iarocci projects:

+

Eve Course

+

This course will teach you how to build RESTful services with Eve and MongoDB.

+

The teacher is the project creator and maintainer.

Useful Links

+ +

Other Projects

+ +

More Nicola Iarocci projects:

+ -

You are looking at the documentation of the development version.

diff --git a/docs/_themes/flask/layout.html b/docs/_themes/flask/layout.html index a3b5480ec..1e8d4a4ce 100644 --- a/docs/_themes/flask/layout.html +++ b/docs/_themes/flask/layout.html @@ -17,7 +17,7 @@ - + Fork me on GitHub {% if pagename == 'index' %} diff --git a/docs/_themes/flask/static/flasky.css_t b/docs/_themes/flask/static/flasky.css_t index 814dea777..bc8563b69 100644 --- a/docs/_themes/flask/static/flasky.css_t +++ b/docs/_themes/flask/static/flasky.css_t @@ -8,11 +8,11 @@ {% set page_width = '940px' %} {% set sidebar_width = '220px' %} - + @import url("basic.css"); - + /* -- page layout ----------------------------------------------------------- */ - + body { font-family: 'Georgia', serif; font-size: 17px; @@ -43,7 +43,7 @@ div.sphinxsidebar { hr { border: 1px solid #B1B4B6; } - + div.body { background-color: #ffffff; color: #3E4349; @@ -54,7 +54,7 @@ img.floatingflask { padding: 0 0 10px 10px; float: right; } - + div.footer { width: {{ page_width }}; margin: 20px auto 30px auto; @@ -70,7 +70,7 @@ div.footer a { div.related { display: none; } - + div.sphinxsidebar a { color: #444; text-decoration: none; @@ -80,7 +80,7 @@ div.sphinxsidebar a { div.sphinxsidebar a:hover { border-bottom: 1px solid #999; } - + div.sphinxsidebar { font-size: 14px; line-height: 1.5; @@ -95,7 +95,7 @@ div.sphinxsidebarwrapper p.logo { margin: 0; text-align: center; } - + div.sphinxsidebar h3, div.sphinxsidebar h4 { font-family: 'Garamond', 'Georgia', serif; @@ -109,7 +109,7 @@ div.sphinxsidebar h4 { div.sphinxsidebar h4 { font-size: 20px; } - + div.sphinxsidebar h3 a { color: #444; } @@ -120,7 +120,7 @@ div.sphinxsidebar p.logo a:hover, div.sphinxsidebar h3 a:hover { border: none; } - + div.sphinxsidebar p { color: #555; margin: 10px 0; @@ -131,25 +131,25 @@ div.sphinxsidebar ul { padding: 0; color: #000; } - + div.sphinxsidebar input { border: 1px solid #ccc; font-family: 'Georgia', serif; font-size: 1em; } - + /* -- body styles ----------------------------------------------------------- */ - + a { color: #004B6B; text-decoration: underline; } - + a:hover { color: #6D4100; text-decoration: underline; } - + div.body h1, div.body h2, div.body h3, @@ -175,18 +175,18 @@ div.body h3 { font-size: 150%; } div.body h4 { font-size: 130%; } div.body h5 { font-size: 100%; } div.body h6 { font-size: 100%; } - + a.headerlink { color: #ddd; padding: 0 4px; text-decoration: none; } - + a.headerlink:hover { color: #444; background: #eaeaea; } - + div.body p, div.body dd, div.body li { line-height: 1.4em; } @@ -233,20 +233,20 @@ div.note { background-color: #eee; border: 1px solid #ccc; } - + div.seealso { background-color: #ffc; border: 1px solid #ff6; } - + div.topic { background-color: #eee; } - + p.admonition-title { display: inline; } - + p.admonition-title:after { content: ":"; } @@ -348,7 +348,7 @@ ul, ol { margin: 10px 0 10px 30px; padding: 0; } - + pre { background: #eee; padding: 7px 30px; @@ -365,7 +365,7 @@ dl dl pre { margin-left: -90px; padding-left: 90px; } - + tt { background-color: #ecf0f3; color: #222; diff --git a/docs/_themes/flask_small/static/flasky.css_t b/docs/_themes/flask_small/static/flasky.css_t index fe2141c56..71961a272 100644 --- a/docs/_themes/flask_small/static/flasky.css_t +++ b/docs/_themes/flask_small/static/flasky.css_t @@ -8,11 +8,11 @@ * :license: BSD, see LICENSE for details. * */ - + @import url("basic.css"); - + /* -- page layout ----------------------------------------------------------- */ - + body { font-family: 'Georgia', serif; font-size: 17px; @@ -35,7 +35,7 @@ div.bodywrapper { hr { border: 1px solid #B1B4B6; } - + div.body { background-color: #ffffff; color: #3E4349; @@ -46,7 +46,7 @@ img.floatingflask { padding: 0 0 10px 10px; float: right; } - + div.footer { text-align: right; color: #888; @@ -55,12 +55,12 @@ div.footer { width: 650px; margin: 0 auto 40px auto; } - + div.footer a { color: #888; text-decoration: underline; } - + div.related { line-height: 32px; color: #888; @@ -69,18 +69,18 @@ div.related { div.related ul { padding: 0 0 0 10px; } - + div.related a { color: #444; } - + /* -- body styles ----------------------------------------------------------- */ - + a { color: #004B6B; text-decoration: underline; } - + a:hover { color: #6D4100; text-decoration: underline; @@ -89,7 +89,7 @@ a:hover { div.body { padding-bottom: 40px; /* saved for footer */ } - + div.body h1, div.body h2, div.body h3, @@ -109,24 +109,24 @@ div.indexwrapper h1 { height: {{ theme_index_logo_height }}; } {% endif %} - + div.body h2 { font-size: 180%; } div.body h3 { font-size: 150%; } div.body h4 { font-size: 130%; } div.body h5 { font-size: 100%; } div.body h6 { font-size: 100%; } - + a.headerlink { color: white; padding: 0 4px; text-decoration: none; } - + a.headerlink:hover { color: #444; background: #eaeaea; } - + div.body p, div.body dd, div.body li { line-height: 1.4em; } @@ -164,25 +164,25 @@ div.note { background-color: #eee; border: 1px solid #ccc; } - + div.seealso { background-color: #ffc; border: 1px solid #ff6; } - + div.topic { background-color: #eee; } - + div.warning { background-color: #ffe4e4; border: 1px solid #f66; } - + p.admonition-title { display: inline; } - + p.admonition-title:after { content: ":"; } @@ -254,7 +254,7 @@ dl { dl dd { margin-left: 30px; } - + pre { padding: 0; margin: 15px -30px; diff --git a/docs/_themes/flask_theme_support.py b/docs/_themes/flask_theme_support.py index 33f47449c..9e598ba6f 100644 --- a/docs/_themes/flask_theme_support.py +++ b/docs/_themes/flask_theme_support.py @@ -1,7 +1,8 @@ # flasky extensions. flasky pygments style based on tango style from pygments.style import Style -from pygments.token import Keyword, Name, Comment, String, Error, \ - Number, Operator, Generic, Whitespace, Punctuation, Other, Literal +from pygments.token import (Comment, Error, Generic, Keyword, Literal, Name, + Number, Operator, Other, Punctuation, String, + Whitespace) class FlaskyStyle(Style): @@ -10,77 +11,68 @@ class FlaskyStyle(Style): styles = { # No corresponding class for the following: - #Text: "", # class: '' - Whitespace: "underline #f8f8f8", # class: 'w' - Error: "#a40000 border:#ef2929", # class: 'err' - Other: "#000000", # class 'x' - - Comment: "italic #8f5902", # class: 'c' - Comment.Preproc: "noitalic", # class: 'cp' - - Keyword: "bold #004461", # class: 'k' - Keyword.Constant: "bold #004461", # class: 'kc' - Keyword.Declaration: "bold #004461", # class: 'kd' - Keyword.Namespace: "bold #004461", # class: 'kn' - Keyword.Pseudo: "bold #004461", # class: 'kp' - Keyword.Reserved: "bold #004461", # class: 'kr' - Keyword.Type: "bold #004461", # class: 'kt' - - Operator: "#582800", # class: 'o' - Operator.Word: "bold #004461", # class: 'ow' - like keywords - - Punctuation: "bold #000000", # class: 'p' - + # Text: "", # class: '' + Whitespace: "underline #f8f8f8", # class: 'w' + Error: "#a40000 border:#ef2929", # class: 'err' + Other: "#000000", # class 'x' + Comment: "italic #8f5902", # class: 'c' + Comment.Preproc: "noitalic", # class: 'cp' + Keyword: "bold #004461", # class: 'k' + Keyword.Constant: "bold #004461", # class: 'kc' + Keyword.Declaration: "bold #004461", # class: 'kd' + Keyword.Namespace: "bold #004461", # class: 'kn' + Keyword.Pseudo: "bold #004461", # class: 'kp' + Keyword.Reserved: "bold #004461", # class: 'kr' + Keyword.Type: "bold #004461", # class: 'kt' + Operator: "#582800", # class: 'o' + Operator.Word: "bold #004461", # class: 'ow' - like keywords + Punctuation: "bold #000000", # class: 'p' # because special names such as Name.Class, Name.Function, etc. # are not recognized as such later in the parsing, we choose them # to look the same as ordinary variables. - Name: "#000000", # class: 'n' - Name.Attribute: "#c4a000", # class: 'na' - to be revised - Name.Builtin: "#004461", # class: 'nb' - Name.Builtin.Pseudo: "#3465a4", # class: 'bp' - Name.Class: "#000000", # class: 'nc' - to be revised - Name.Constant: "#000000", # class: 'no' - to be revised - Name.Decorator: "#888", # class: 'nd' - to be revised - Name.Entity: "#ce5c00", # class: 'ni' - Name.Exception: "bold #cc0000", # class: 'ne' - Name.Function: "#000000", # class: 'nf' - Name.Property: "#000000", # class: 'py' - Name.Label: "#f57900", # class: 'nl' - Name.Namespace: "#000000", # class: 'nn' - to be revised - Name.Other: "#000000", # class: 'nx' - Name.Tag: "bold #004461", # class: 'nt' - like a keyword - Name.Variable: "#000000", # class: 'nv' - to be revised - Name.Variable.Class: "#000000", # class: 'vc' - to be revised - Name.Variable.Global: "#000000", # class: 'vg' - to be revised - Name.Variable.Instance: "#000000", # class: 'vi' - to be revised - - Number: "#990000", # class: 'm' - - Literal: "#000000", # class: 'l' - Literal.Date: "#000000", # class: 'ld' - - String: "#4e9a06", # class: 's' - String.Backtick: "#4e9a06", # class: 'sb' - String.Char: "#4e9a06", # class: 'sc' - String.Doc: "italic #8f5902", # class: 'sd' - like a comment - String.Double: "#4e9a06", # class: 's2' - String.Escape: "#4e9a06", # class: 'se' - String.Heredoc: "#4e9a06", # class: 'sh' - String.Interpol: "#4e9a06", # class: 'si' - String.Other: "#4e9a06", # class: 'sx' - String.Regex: "#4e9a06", # class: 'sr' - String.Single: "#4e9a06", # class: 's1' - String.Symbol: "#4e9a06", # class: 'ss' - - Generic: "#000000", # class: 'g' - Generic.Deleted: "#a40000", # class: 'gd' - Generic.Emph: "italic #000000", # class: 'ge' - Generic.Error: "#ef2929", # class: 'gr' - Generic.Heading: "bold #000080", # class: 'gh' - Generic.Inserted: "#00A000", # class: 'gi' - Generic.Output: "#888", # class: 'go' - Generic.Prompt: "#745334", # class: 'gp' - Generic.Strong: "bold #000000", # class: 'gs' - Generic.Subheading: "bold #800080", # class: 'gu' - Generic.Traceback: "bold #a40000", # class: 'gt' + Name: "#000000", # class: 'n' + Name.Attribute: "#c4a000", # class: 'na' - to be revised + Name.Builtin: "#004461", # class: 'nb' + Name.Builtin.Pseudo: "#3465a4", # class: 'bp' + Name.Class: "#000000", # class: 'nc' - to be revised + Name.Constant: "#000000", # class: 'no' - to be revised + Name.Decorator: "#888", # class: 'nd' - to be revised + Name.Entity: "#ce5c00", # class: 'ni' + Name.Exception: "bold #cc0000", # class: 'ne' + Name.Function: "#000000", # class: 'nf' + Name.Property: "#000000", # class: 'py' + Name.Label: "#f57900", # class: 'nl' + Name.Namespace: "#000000", # class: 'nn' - to be revised + Name.Other: "#000000", # class: 'nx' + Name.Tag: "bold #004461", # class: 'nt' - like a keyword + Name.Variable: "#000000", # class: 'nv' - to be revised + Name.Variable.Class: "#000000", # class: 'vc' - to be revised + Name.Variable.Global: "#000000", # class: 'vg' - to be revised + Name.Variable.Instance: "#000000", # class: 'vi' - to be revised + Number: "#990000", # class: 'm' + Literal: "#000000", # class: 'l' + Literal.Date: "#000000", # class: 'ld' + String: "#4e9a06", # class: 's' + String.Backtick: "#4e9a06", # class: 'sb' + String.Char: "#4e9a06", # class: 'sc' + String.Doc: "italic #8f5902", # class: 'sd' - like a comment + String.Double: "#4e9a06", # class: 's2' + String.Escape: "#4e9a06", # class: 'se' + String.Heredoc: "#4e9a06", # class: 'sh' + String.Interpol: "#4e9a06", # class: 'si' + String.Other: "#4e9a06", # class: 'sx' + String.Regex: "#4e9a06", # class: 'sr' + String.Single: "#4e9a06", # class: 's1' + String.Symbol: "#4e9a06", # class: 'ss' + Generic: "#000000", # class: 'g' + Generic.Deleted: "#a40000", # class: 'gd' + Generic.Emph: "italic #000000", # class: 'ge' + Generic.Error: "#ef2929", # class: 'gr' + Generic.Heading: "bold #000080", # class: 'gh' + Generic.Inserted: "#00A000", # class: 'gi' + Generic.Output: "#888", # class: 'go' + Generic.Prompt: "#745334", # class: 'gp' + Generic.Strong: "bold #000000", # class: 'gs' + Generic.Subheading: "bold #800080", # class: 'gu' + Generic.Traceback: "bold #a40000", # class: 'gt' } diff --git a/docs/authentication.rst b/docs/authentication.rst index d79c1de5d..3933f9940 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -23,7 +23,7 @@ why you are provided with a handful of base authentication classes. They implement the basic authentication mechanism and must be subclassed in order to implement authorization logic. No matter which authentication scheme you pick the only thing that you need to do in your subclass is override the -``check_auth()`` method. +``check_auth()`` method. Global Authentication --------------------- @@ -56,9 +56,9 @@ to provide the correct credentials in order to consume the API: HTTP/1.1 200 OK By default access is restricted to all endpoints for all HTTP verbs -(methods), effectively locking down the whole API. +(methods), effectively locking down the whole API. -But what if your authorization logic is more complex, and you only want to +But what if your authentication logic is more complex, and you only want to secure some endpoints or apply different logics depending on the endpoint being consumed? You could get away with just adding logic to your authentication class, maybe with something like this: @@ -76,14 +76,14 @@ authentication class, maybe with something like this: If needed, this approach also allows to take the request ``method`` into consideration, for example to allow ``GET`` requests for everyone while forcing -validation on edits (``POST``, ``PUT``, ``PATCH``, ``DELETE``). +validation on edits (``POST``, ``PUT``, ``PATCH``, ``DELETE``). Endpoint-level Authentication ----------------------------- The *one class to bind them all* approach seen above is probably good for most use cases but as soon as authorization logic gets more complicated it could easily lead to complex and unmanageable code, something you don't really want -to have when dealing with security. +to have when dealing with security. Wouldn't it be nice if we could have specialized auth classes that we could freely apply to selected endpoints? This way the global level auth class, the @@ -93,7 +93,7 @@ Alternatively, we could even choose to *not* provide a global auth class, effectively making all endpoints public, except the ones we want protected. With a system like this we could even choose to have some endpoints protected with, say, Basic Authentication while others are secured with Token, or HMAC -Authentication! +Authentication! Well, turns out this is actually possible by simply enabling the resource-level ``authentication`` setting when we are defining the API @@ -106,7 +106,7 @@ resource-level ``authentication`` setting when we are defining the API 'authentication': MySuperCoolAuth, ... }, - 'invoices': ... + 'invoices': ... } And that's it. The `people` endpoint will now be using the ``MySuperCoolAuth`` @@ -118,7 +118,7 @@ There are other features and options that you can use to reduce complexity in your auth classes, especially (but not only) when using the global level authentication system. Lets review them. -Global Endpoint Security +Global Endpoint Security ------------------------ You might want a public read-only API where only authorized users can write, edit and delete. You can achieve that by using the ``PUBLIC_METHODS`` and @@ -127,7 +127,7 @@ your `settings.py`: :: - PUBLIC_METHODS = ['GET'] + PUBLIC_METHODS = ['GET'] PUBLIC_ITEM_METHODS = ['GET'] And run your API. POST, PATCH and DELETE are still restricted, while GET is @@ -159,7 +159,7 @@ first open read access for all endpoints: :: - PUBLIC_METHODS = ['GET'] + PUBLIC_METHODS = ['GET'] PUBLIC_ITEM_METHODS = ['GET'] Then you protect the private endpoint: @@ -188,7 +188,7 @@ Basic Authentication with bcrypt Encoding passwords with bcrypt_ is a great idea. It comes at the cost of performance, but that's precisely the point, as slow encoding means very good resistance to brute-force attacks. For a faster (and less safe) alternative, see -the SHA1/MAC snippet further below. +the SHA1/MAC snippet further below. This script assumes that user accounts are stored in an `accounts` MongoDB collection, and that passwords are stored as bcrypt hashes. All API @@ -310,7 +310,7 @@ resources and/or methods to public access -see docs). def check_auth(self, token, allowed_roles, resource, method): """For the purpose of this example the implementation is as simple as possible. A 'real' token should probably contain a hash of the - username/password combo, which sould then validated against the account + username/password combo, which should then be validated against the account data stored on the DB. """ # use Eve's own db driver; no additional connections/resources are used @@ -327,7 +327,7 @@ HMAC Authentication The ``eve.auth.HMACAuth`` class allows for custom, Amazon S3-like, HMAC (Hash Message Authentication Code) authentication, which is basically a very secure custom authentication scheme built around the `Authorization` header. - + How HMAC Authentication Works ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The server provides the client with a user id and a secret key through some @@ -337,7 +337,7 @@ secret key to sign all requests. When the client wants to send a request, he builds the complete request and then, using the secret key, computes a hash over the complete message body (and -optionally some of the message headers if required) +optionally some of the message headers if required) Next, the client adds the computed hash and his userid to the message in the Authorization header: @@ -360,7 +360,7 @@ temporarily work on your behalf. This is also the reason why the secret key is generally provided through out-of-band channels (often a webpage or, as said above, an email or plain old paper). -The ``eve.auth.HMACAuth`` class also support access roles. +The ``eve.auth.HMACAuth`` class also support access roles. HMAC Example ~~~~~~~~~~~~ @@ -379,7 +379,7 @@ Eve `repository`_. class HMACAuth(HMACAuth): def check_auth(self, userid, hmac_hash, headers, data, allowed_roles, resource, method): - # use Eve's own db driver; no additional connections/resources are + # use Eve's own db driver; no additional connections/resources are # used accounts = app.data.driver.db['accounts'] user = accounts.find_one({'userid': userid}) @@ -413,7 +413,7 @@ settings ` (or the corresponding ``allowed_roles`` and ``allowed_item_ro ALLOWED_ROLES = ['admin'] Then your subclass would implement the authorization logic by making good use -of the aforementioned ``allowed_roles`` parameter. +of the aforementioned ``allowed_roles`` parameter. The snippet below assumes that user accounts are stored in an `accounts` MongoDB collection, that passwords are stored as SHA1/HMAC hashes and that user @@ -458,7 +458,7 @@ unless they are made explicitly public. if __name__ == '__main__': app = Eve(auth=RolesAuth) app.run() - + .. _user-restricted: User-Restricted Resource Access @@ -508,7 +508,7 @@ BCrypt-authentication example from above: # use Eve's own db driver; no additional connections/resources are used accounts = app.data.driver.db['accounts'] account = accounts.find_one({'username': username}) - # set 'auth_field' value to the account's ObjectId + # set 'auth_field' value to the account's ObjectId # (instead of _id, you might want to use ID_FIELD) if account and '_id' in account: self.set_request_auth_value(account['_id']) @@ -525,7 +525,7 @@ BCrypt-authentication example from above: Auth-driven Database Access --------------------------- Custom authentication classes can also set the database that should be used -when serving the active request. +when serving the active request. Normally you either use a single database for the whole API or you configure which database each endpoint consumes by setting ``mongo_prefix`` to the @@ -559,7 +559,7 @@ A trivial example would be: The above class will serve ``user1`` with data coming from the database which configuration settings are prefixed by ``MONGO1`` in ``settings.py``. Same happens with ``user2`` and ``MONGO2`` while all other users are served with -the default database. +the default database. Since values set by ``set_mongo_prefix()`` have precedence over both default and endpoint-level ``mongo_prefix`` settings, what happens here is that the two @@ -579,7 +579,7 @@ OAuth2. The snippets in this page can also be found in the `examples/security` folder of the Eve `repository`_. -.. _`repository`: https://github.com/nicolaiarocci/eve +.. _`repository`: https://github.com/pyeve/eve .. _bcrypt: http://en.wikipedia.org/wiki/Bcrypt -.. _`Eve-OAuth2`: https://github.com/nicolaiarocci/eve-oauth2 -.. _`Flask-Sentinel`: https://github.com/nicolaiarocci/flask-sentinel +.. _`Eve-OAuth2`: https://github.com/pyeve/eve-oauth2 +.. _`Flask-Sentinel`: https://github.com/pyeve/flask-sentinel diff --git a/docs/changelog.rst b/docs/changelog.rst index 1b741623f..f6a352994 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,5 +1,3 @@ .. _changelog: -.. include:: ../CHANGES - - +.. include:: ../CHANGES.rst diff --git a/docs/conf.py b/docs/conf.py index e2421a793..ed0add9c6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,115 +11,114 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys, os, datetime -import alabaster +import datetime +import os +import sys # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.append(os.path.abspath('.')) -sys.path.append(os.path.abspath('..')) -sys.path.append(os.path.abspath('_themes')) +sys.path.append(os.path.abspath(".")) +sys.path.append(os.path.abspath("..")) +sys.path.append(os.path.abspath("_themes")) # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'alabaster', - 'sphinxcontrib.embedly'] - -# sphinxcontrib.embedly -embedly_key = '76207aa23dde489bba6bcbc9e56193a6' +extensions = ["sphinx.ext.autodoc", "sphinx.ext.intersphinx"] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'Eve' -copyright = u'%s. Python-Eve is a Nicola Iarocci Project' % datetime.datetime.now().year +project = "Eve" +copyright = ( + '%s. Python-Eve is a Nicola Iarocci Project' + % datetime.datetime.now().year +) # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The full version, including alpha/beta/rc tags. -release = __import__('eve').__version__ +release = __import__("eve").__version__ # The short X.Y version. -version = release.split('-dev')[0] +version = release.split(".dev")[0] # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -#pygments_style = 'sphinx' +# pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -#html_theme = 'default' -#html_theme = 'flask' -html_theme = 'alabaster' +# html_theme = 'default' +# html_theme = 'flask' +html_theme = "alabaster" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {'touch_icon': 'touch-icon.png'} +# html_theme_options = {'touch_icon': 'touch-icon.png'} # Add any paths that contain custom themes here, relative to this directory. -html_theme_path = [alabaster.get_path()] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = "favicon.png" +# html_logo = "favicon.png" # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 @@ -129,128 +128,122 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} -#html_sidebars = { +# html_sidebars = {} +# html_sidebars = { # 'index': ['sidebarintro.html', 'searchbox.html', 'sidebarfooter.html'], # '**': ['sidebarlogo.html', 'localtoc.html', 'relations.html', # 'sourcelink.html', 'searchbox.html'] -#} +# } html_sidebars = { - '**': [ - 'about.html', - 'sidebarintro.html', - 'navigation.html', - 'searchbox.html', - 'artwork.html', - ] + "**": [ + "about.html", + "sidebarintro.html", + "navigation.html", + "searchbox.html", + "artwork.html", + ] } html_theme_options = { - 'logo': 'eve-sidebar.png', - 'github_user': 'nicolaiarocci', - 'github_repo': 'eve', - 'github_banner': True, - 'github_banner_image': 'forkme_right_green_007200.png', - 'show_powered_by': False, + "logo": "eve_leaf.png", + "github_user": "pyeve", + "github_repo": "eve", + "github_type": "star", + "github_banner": "forkme_right_green_007200.png", + "show_powered_by": False, } # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. html_domain_indices = False -#html_use_modindex = False +# html_use_modindex = False # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. html_show_sourcelink = False # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'Evedoc' +htmlhelp_basename = "Evedoc" # -- Options for LaTeX output -------------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # The paper size ('letterpaper' or 'a4paper'). + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # 'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'Eve.tex', u'Eve Documentation', - u'Nicola Iarocci', 'manual'), + ("index", "Eve.tex", "Eve Documentation", "Nicola Iarocci", "manual") ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'eve', u'Eve Documentation', - [u'Nicola Iarocci'], 1) -] +man_pages = [("index", "eve", "Eve Documentation", ["Nicola Iarocci"], 1)] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ @@ -259,38 +252,44 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'Eve', u'Eve Documentation', - u'Nicola Iarocci', 'Eve', 'One line description of project.', - 'Miscellaneous'), + ( + "index", + "Eve", + "Eve Documentation", + "Nicola Iarocci", + "Eve", + "One line description of project.", + "Miscellaneous", + ) ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # Example configuration for intersphinx: refer to the Python standard library. -#intersphinx_mapping = {'http://docs.python.org/': None} -intersphinx_mapping = {'cerberus': ('http://docs.python-cerberus.org/en/latest/', None)} +# intersphinx_mapping = {'http://docs.python.org/': None} +intersphinx_mapping = {"cerberus": ("http://docs.python-cerberus.org/en/latest/", None)} -pygments_style = 'flask_theme_support.FlaskyStyle' +pygments_style = "flask_theme_support.FlaskyStyle" # fall back if theme is not there try: - __import__('flask_theme_support') -except ImportError, e: - print '-' * 74 - print 'Warning: Flask themes unavailable. Building with default theme' - print 'If you want the Flask themes, run this command and build again:' + __import__("flask_theme_support") +except ImportError: + print("-" * 74) + print("Warning: Flask themes unavailable. Building with default theme") + print("If you want the Flask themes, run this command and build again:") print - print ' git submodule update --init' - print '-' * 74 + print(" git submodule update --init") + print("-" * 74) - pygments_style = 'tango' - html_theme = 'default' + pygments_style = "tango" + html_theme = "default" html_theme_options = {} diff --git a/docs/config.rst b/docs/config.rst index eecff35d0..a655902c9 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -3,13 +3,22 @@ Configuration ============= Generally Eve configuration is best done with configuration files. The -configuration files themselves are actual Python files. +configuration files themselves are actual Python files. However, Eve will +give precedence to dictionary-based settings first, then it will try to +locate a file passed in :envvar:`EVE_SETTINGS` environmental variable (if +set) and finally it will try to locate `settings.py` or a file with filename +passed to `settings` flag in constructor. -Configuration with Files +Configuration With Files ------------------------ -On startup, Eve will look for a `settings.py` file in the application folder. -You can choose an alternative filename/path. Just pass it as an argument when -you instantiate the application. +On startup, if `settings` flag is omitted in constructor, Eve will try to locate +file named `settings.py`, first in the application folder and then in one of the +application's subfolders. You can choose an alternative filename/path, just pass +it as an argument when you instantiate the application. If the file path is +relative, Eve will try to locate it recursively in one of the folders in your +`sys.path`, therefore you have to be sure that your application root is appended +to it. This is useful, for example, in testing environments, when settings file +is not necessarily located in the root of your application. .. code-block:: python @@ -18,12 +27,17 @@ you instantiate the application. app = Eve(settings='my_settings.py') app.run() -Configuration with a Dictionary +Configuration With a Dictionary ------------------------------- -Alternatively, you can choose to provide a settings dictionary: +Alternatively, you can choose to provide a settings dictionary. Unlike +configuring Eve with the settings file, dictionary-based approach will only +update Eve's default settings with your own values, rather than overwriting +all the settings. .. code-block:: python + from eve import Eve + my_settings = { 'MONGO_HOST': 'localhost', 'MONGO_PORT': 27017, @@ -31,8 +45,6 @@ Alternatively, you can choose to provide a settings dictionary: 'DOMAIN': {'contacts': {}} } - from eve import Eve - app = Eve(settings=my_settings) app.run() @@ -54,8 +66,7 @@ There are many alternative ways to handle development/production however. Using Python modules for configuration is very convenient, as they allow for all kinds of nice tricks, like being able to seamlessly launch the same API on both local and production systems, connecting to the appropriate -database instance as needed. Consider the following example, taken directly -from the :ref:`demo`: +database instance as needed. Consider the following example: :: @@ -102,6 +113,14 @@ uppercase. ``/v1/``). Defaults to ``''``. ``ALLOWED_FILTERS`` List of fields on which filtering is allowed. + Entries in this list work in a hierarchical + way. This means that, for instance, filtering + on ``'dict.sub_dict.foo'`` is allowed if + ``ALLOWED_FILTERS`` contains any of + ``'dict.sub_dict.foo``, ``'dict.sub_dict'`` + or ``'dict'``. Instead filtering on + ``'dict'`` is allowed if ``ALLOWED_FILTERS`` + contains ``'dict'``. Can be set to ``[]`` (no filters allowed) or ``['*']`` (filters allowed on every field). Unless your API is comprised of @@ -111,15 +130,37 @@ uppercase. local level (see ``allowed_filters`` below). Defaults to ``['*']``. - *Please note:* If API scraping or DB DoS - attacks are a concern, then globally - disabling filters and whitelisting valid - ones at the local level is the way to go. + .. warning:: + + **Security:** With the default setting + (``['*']``), clients can filter on + *any* field, including sensitive ones + such as password hashes or tokens. + MongoDB query operators like ``$gt``, + ``$lt``, and ``$ne`` can be used by + attackers to perform blind enumeration + of field values. For production + deployments, globally disable filters + (set to ``[]``) and explicitly + whitelist only the fields you intend + to be queryable at the resource level + via ``allowed_filters``. Never allow + filtering on fields that store secrets + or credentials. ``VALIDATE_FILTERS`` Whether to validate the filters against the resource schema. Invalid filters will throw an exception. Defaults to ``False``. + Word of caution: validation on filter + expressions involving fields with custom + rules or types might have a considerable + impact on performance. This is the case, + for example, with ``data_relation``-rule + fields. Consider excluding heavy-duty + fields from filters (see + ``ALLOWED_FILTERS``). + ``SORTING`` ``True`` if sorting is supported for ``GET`` requests, otherwise ``False``. Can be overridden by resource settings. Defaults @@ -130,16 +171,31 @@ uppercase. overridden by resource settings. Defaults to ``True``. -``PAGINATION_LIMIT`` Maximum value allowed for QUERY_MAX_RESULTS +``PAGINATION_LIMIT`` Maximum value allowed for ``QUERY_MAX_RESULTS`` query parameter. Values exceeding the limit will be silently replaced with this value. You want to aim for a reasonable compromise between performance and transfer size. Defaults to 50. -``PAGINATION_DEFAULT`` Default value for QUERY_MAX_RESULTS. +``PAGINATION_DEFAULT`` Default value for ``QUERY_MAX_RESULTS``. Defaults to 25. +``OPTIMIZE_PAGINATION_FOR_SPEED`` Set this to ``True`` to improve pagination + performance. When optimization is active no + count operation, which can be slow on large + collections, is performed on the database. + This does have a few consequences. + Firstly, no document count is returned. + Secondly, ``HATEOAS`` is less accurate: no + last page link is available, and next page + link is always included, even on last page. + On big collections, switching this feature + on can greatly improve performance. + Defaults to ``False`` (slower performance; + document count included; accurate + ``HATEOAS``). + ``QUERY_WHERE`` Key for the filters query parameter. Defaults to ``where``. ``QUERY_SORT`` Key for the sort query parameter. Defaults to ``sort``. @@ -253,9 +309,15 @@ uppercase. domains are allowed to perform CORS requests. Allowed values are: ``None``, a list of domains, or ``'*'`` for - a wide-open API. Regexes are allowed, which - is useful for websites with dynamic ranges - of subdomains. Defaults to ``None``. + a wide-open API. Defaults to ``None``. + +``X_DOMAINS_RE`` The same setting as ``X_DOMAINS``, but a list + of regexes is allowed. This is useful for + websites with dynamic ranges of + subdomains. Make sure to properly anchor and + escape the regexes. Invalid + regexes (such as ``'*'``) are ignored. + Defaults to ``None``. ``X_HEADERS`` CORS (Cross-Origin Resource Sharing) support. Allows API maintainers to specify which @@ -337,9 +399,6 @@ uppercase. :ref:`unknown` for more information. Defaults to ``False``. -``TRANSPARENT_SCHEMA_RULES`` When ``True``, this option globally disables - :ref:`schema_validation` for any API endpoint. - ``PROJECTION`` When ``True``, this option enables the :ref:`projections` feature. Can be overridden by resource settings. Defaults @@ -423,7 +482,6 @@ uppercase. ``META`` Allows to customize the meta field. Defaults to ``_meta`` - to ``_meta``. ``INFO`` String value to include an info section, with the given INFO name, at the Eve homepage (suggested @@ -446,17 +504,18 @@ uppercase. it is enabled, ``False`` otherwise. Defaults to ``True``. See :ref:`concurrency`. -``XML`` ``True`` to enable XML support, ``False`` - otherwise. See :ref:`jsonxml`. Defaults to - ``True``. - -``JSON`` ``True`` to enable JSON support, ``False`` - otherwise. See :ref:`jsonxml`. Defaults to - ``True``. +``RENDERERS`` Allows to change enabled renderers. Defaults to + ``['eve.render.JSONRenderer', 'eve.render.XMLRenderer']``. ``JSON_SORT_KEYS`` ``True`` to enable JSON key sorting, ``False`` otherwise. Defaults to ``False``. +``JSON_REQUEST_CONTENT_TYPES`` Supported JSON content types. Useful when + you need support for vendor-specific json + types. Please note: responses will still + carry the standard ``application/json`` + type. Defaults to ``['application/json']``. + ``VALIDATION_ERROR_STATUS`` The HTTP status code to use for validation errors. Defaults to ``422``. @@ -497,10 +556,6 @@ uppercase. ``MONGO_URI`` A `MongoDB URI`_ which is used in preference of the other configuration variables. -``MONGO_OPTIONS`` MongoDB keyword arguments to passed to - MongoClient class ``__init__``. - Defaults to ``{'connect': True}``. - ``MONGO_HOST`` MongoDB server address. Defaults to ``localhost``. ``MONGO_PORT`` MongoDB port. Defaults to ``27017``. @@ -511,30 +566,19 @@ uppercase. ``MONGO_DBNAME`` MongoDB database name. -``MONGO_AUTHDBNAME`` MongoDB authorization database name. Defaults to ``None``. - -``MONGO_MAX_POOL_SIZE`` The maximum number of idle connections - maintained in the PyMongo connection pool. - Default: PyMongo default. - -``MONGO_SOCKET_TIMEOUT_MS`` How long (in milliseconds) a send or - receive on a socket can take before timing - out. Default: PyMongo default. +``MONGO_OPTIONS`` MongoDB keyword arguments to passed to + MongoClient class ``__init__``. + Defaults to ``{'connect': True, 'tz_aware': True, 'appname': 'flask_app_name', 'uuidRepresentation': 'standard'}``. + See `PyMongo mongo_client`_ for reference. -``MONGO_CONNECT_TIMEOUT_MS`` How long (in milliseconds) a connection can - take to be opened before timing out. - Default: PyMongo default. +``MONGO_AUTH_SOURCE`` MongoDB authorization database. Defaults to ``None``. -``MONGO_REPLICA_SET`` The name of a replica set to connect to; - this must match the internal name of the - replica set (as deteremined by the - `isMaster `_ - command). Default: ``None``. +``MONGO_AUTH_MECHANISM`` MongoDB authentication mechanism. + See `PyMongo Authentication Mechanisms`_. + Defaults to ``None``. -``MONGO_READ_PREFERENCE`` Determines how read queries are routed to - the replica set members. Must be one of the - constants defined on PyMongo's ReadPreference_, - or the string names thereof. +``MONGO_AUTH_MECHANISM_PROPERTIES`` Specify MongoDB extra authentication mechanism properties + if required. Defaults to ``None``. ``MONGO_QUERY_BLACKLIST`` A list of Mongo query operators that are not allowed to be used in resource filters @@ -548,6 +592,23 @@ uppercase. easily replaced with the (very rich) Mongo query dialect. + .. warning:: + + Removing ``$where`` or ``$regex`` from + this list exposes your application to + server-side JavaScript injection and + ReDoS attacks. Only do so if you fully + understand the implications. + +``MONGO_QUERY_WHITELIST`` A list of extra Mongo query operators to allow + besides the official list of allowed operators. + Defaults to ``[]``. + + Can be overridden at endpoint (Mongo + collection) level. See + ``mongo_query_whitelist`` below. + + ``MONGO_WRITE_CONCERN`` A dictionary defining MongoDB write concern settings. All standard write concern settings (w, wtimeout, j, fsync) are @@ -610,6 +671,26 @@ uppercase. should be formatted at :ref:`multipart`. Defaults to ``False``. +``AUTO_COLLAPSE_MULTI_KEYS`` If set to ``True``, multiple values sent + with the same key, submitted using the + ``application/x-www-form-urlencoded`` or + ``multipart/form-data`` content types, + will automatically be converted to a list of + values. + + When using this together with + ``AUTO_CREATE_LISTS`` it becomes possible + to use lists of media fields. + + Defaults to ``False`` + +``AUTO_CREATE_LISTS`` When submitting a non ``list`` type value + for a field with type ``list``, + automatically create a one element list + before running the validators. + + Defaults to ``False`` + ``OPLOG`` Set it to ``True`` to enable the :ref:`oplog`. Defaults to ``False``. @@ -655,13 +736,19 @@ uppercase. loading posts themselves. Defaults to ``X-Total-Count``. -``JSONP_ARGUMENT`` This option will cause the response to be +``JSONP_ARGUMENT`` .. deprecated:: + JSONP is deprecated and will be removed + in a future release. Use CORS instead. + + This option will cause the response to be wrapped in a JavaScript function call if the argument is set in the request. For example if you set ``JSON_ARGUMENT = 'callback'``, then all responses to ``?callback=funcname`` requests will be - wrapped in a ``funcname`` call. Defaults to + wrapped in a ``funcname`` call. The + callback name is validated to be a safe + JavaScript identifier. Defaults to ``None``. ``BULK_ENABLED`` Enables bulk insert when set to ``True``. @@ -689,7 +776,7 @@ uppercase. altogether. Defaults to ``[400, 401, 403, 404, 405, 406, 409, 410, 412, 422, 428]`` -``VALIDATION_ERROR_AS_STRING`` If ``True`` even single field errors will +``VALIDATION_ERROR_AS_LIST`` If ``True`` even single field errors will be returned in a list. By default single field errors are returned as strings while multiple field errors are bundled in a @@ -711,6 +798,27 @@ uppercase. disable this feature, and a ``404`` will be returned instead. Defaults to ``True``. +``MERGE_NESTED_DOCUMENTS`` If ``True``, updates to nested fields are + merged with the current data on ``PATCH``. + If ``False``, the updates overwrite the + current data. Defaults to ``True``. + +``NORMALIZE_DOTTED_FIELDS`` If ``True``, dotted fields are parsed + and processed as subdocument fields. If + ``False``, dotted fields are left unparsed + and unprocessed, and the payload is passed + to the underlying data-layer as-is. Please + note that with the default Mongo layer, + setting this to ``False`` will result in an + error. Defaults to ``True``. +``NORMALIZE_ON_PATCH`` If ``True``, the patch document will be + normalized according to schema. This means + if a field is not included in the patch + body, it will be reset to the default value + in its schema. If ``False``, the field which + is not included in the patch body will be + kept untouched. Defaults to ``True``. + =================================== ========================================= .. _domain: @@ -745,293 +853,345 @@ always lowercase. .. tabularcolumns:: |p{6.5cm}|p{8.5cm}| -=============================== =============================================== -``url`` The endpoint URL. If omitted the resource key - of the ``DOMAIN`` dict will be used to build - the URL. As an example, ``contacts`` would make - the `people` resource available at - ``/contacts`` (instead of ``/people``). URL can - be as complex as needed and can be nested - relative to another API endpoint (you can have - a ``/contacts`` endpoint and then - a ``/contacts/overseas`` endpoint. Both are - independent of each other and freely - configurable). - - You can also use regexes to setup - subresource-like endpoints. See - :ref:`subresources`. - -``allowed_filters`` List of fields on which filtering is allowed. - Can be set to ``[]`` (no filters allowed), or - ``['*']`` (fields allowed on every field). - Defaults to ``['*']``. - - *Please note:* If API scraping or DB DoS - attacks are a concern, then globally disabling - filters (see ``ALLOWED_FILTERS`` above) and - then whitelisting valid ones at the local level - is the way to go. - -``sorting`` ``True`` if sorting is enabled, ``False`` - otherwise. Locally overrides ``SORTING``. - -``pagination`` ``True`` if pagination is enabled, ``False`` - otherwise. Locally overrides ``PAGINATION``. - -``resource_methods`` A list of HTTP methods supported at resource - endpoint. Allowed values: ``GET``, ``POST``, - ``DELETE``. Locally overrides - ``RESOURCE_METHODS``. - - *Please note:* if you're running version 0.0.5 - or earlier use the now unsupported ``methods`` - keyword instead. - -``public_methods`` A list of HTTP methods supported at resource - endpoint, open to public access even when - :ref:`auth` is enabled. Locally overrides - ``PUBLIC_METHODS``. - -``item_methods`` A list of HTTP methods supported at item - endpoint. Allowed values: ``GET``, ``PATCH``, - ``PUT`` and ``DELETE``. ``PATCH`` or, for - clients not supporting PATCH, ``POST`` with - the ``X-HTTP-Method-Override`` header tag. - Locally overrides ``ITEM_METHODS``. - -``public_item_methods`` A list of HTTP methods supported at item - endpoint, left open to public access when - :ref:`auth` is enabled. Locally overrides - ``PUBLIC_ITEM_METHODS``. - -``allowed_roles`` A list of allowed `roles` for resource - endpoint. See :ref:`auth` for more - information. Locally overrides - ``ALLOWED_ROLES``. - -``allowed_read_roles`` A list of allowed `roles` for resource - endpoint with GET and OPTIONS methods. - See :ref:`auth` for more - information. Locally overrides - ``ALLOWED_READ_ROLES``. - -``allowed_write_roles`` A list of allowed `roles` for resource - endpoint with POST, PUT and DELETE. - See :ref:`auth` for more - information. Locally overrides - ``ALLOWED_WRITE_ROLES``. - -``allowed_item_read_roles`` A list of allowed `roles` for item endpoint - with GET and OPTIONS methods. - See :ref:`auth` for more information. - Locally overrides ``ALLOWED_ITEM_READ_ROLES``. - - -``allowed_item_write_roles`` A list of allowed `roles` for item endpoint - with PUT, PATH and DELETE methods. - See :ref:`auth` for more information. - Locally overrides ``ALLOWED_ITEM_WRITE_ROLES``. - -``allowed_item_roles`` A list of allowed `roles` for item endpoint. - See :ref:`auth` for more information. - Locally overrides ``ALLOWED_ITEM_ROLES``. - -``cache_control`` Value of the ``Cache-Control`` header field - used when serving ``GET`` requests. Leave empty - if you don't want to include cache directives - with API responses. Locally overrides - ``CACHE_CONTROL``. - -``cache_expires`` Value (in seconds) of the ``Expires`` header - field used when serving ``GET`` requests. If - set to a non-zero value, the header will - always be included, regardless of the setting - of ``CACHE_CONTROL``. Locally overrides - ``CACHE_EXPIRES``. - -``id_field`` Field used to uniquely identify resource items - within the database. Locally overrides - ``ID_FIELD``. - -``item_lookup`` ``True`` if item endpoint should be available, - ``False`` otherwise. Locally overrides - ``ITEM_LOOKUP``. - -``item_lookup_field`` Field used when looking up a resource - item. Locally overrides ``ITEM_LOOKUP_FIELD``. - -``item_url`` Rule used to construct item endpoint URL. - Locally overrides ``ITEM_URL``. - -``resource_title`` Title used when building resource links - (HATEOAS). Defaults to resource's ``url``. - -``item_title`` Title to be used when building item references, - both in XML and JSON responses. Overrides - ``ITEM_TITLE``. - -``additional_lookup`` Besides the standard item endpoint which - defaults to ``//``, - you can optionally define a secondary, - read-only, endpoint like - ``//``. You do so by - defining a dictionary comprised of two items - `field` and `url`. The former is the name of - the field used for the lookup. If the field - type (as defined in the resource schema_) is - a string, then you put a URL rule in `url`. If - it is an integer, then you just omit `url`, as - it is automatically handled. See the code - snippet below for an usage example of this - feature. - -``datasource`` Explicitly links API resources to database - collections. See `Advanced Datasource - Patterns`_. - -``auth_field`` Enables :ref:`user-restricted`. When the - feature is enabled, users can only - read/update/delete resource items created by - themselves. The keyword contains the actual - name of the field used to store the id of - the user who created the resource item. Locally - overrides ``AUTH_FIELD``. - -``allow_unknown`` When ``True``, this option will allow insertion - of arbitrary, unknown fields to the endpoint. - Use with caution. Locally overrides - ``ALLOW_UNKNOWN``. See :ref:`unknown` for more - information. Defaults to ``False``. - -``transparent_schema_rules`` When ``True``, this option disables - :ref:`schema_validation` for the endpoint. - -``projection`` When ``True``, this option enables the - :ref:`projections` feature. Locally overrides - ``PROJECTION``. Defaults to ``True``. - -``embedding`` When ``True`` this option enables the - :ref:`embedded_docs` feature. Defaults to - ``True``. - -``extra_response_fields`` Allows to configure a list of additional - document fields that should be provided with - every POST response. Normally only - automatically handled fields (``ID_FIELD``, - ``LAST_UPDATED``, ``DATE_CREATED``, ``ETAG``) - are included in response payloads. Overrides - ``EXTRA_RESPONSE_FIELDS``. - -``hateoas`` When ``False``, this option disables - :ref:`hateoas_feature` for the resource. - Defaults to ``True``. +=================================== =============================================== +``url`` The endpoint URL. If omitted the resource key + of the ``DOMAIN`` dict will be used to build + the URL. As an example, ``contacts`` would make + the `people` resource available at + ``/contacts`` (instead of ``/people``). URL can + be as complex as needed and can be nested + relative to another API endpoint (you can have + a ``/contacts`` endpoint and then + a ``/contacts/overseas`` endpoint. Both are + independent of each other and freely + configurable). + + You can also use regexes to setup + subresource-like endpoints. See + :ref:`subresources`. + +``allowed_filters`` List of fields on which filtering is allowed. + Entries in this list work in a hierarchical + way. This means that, for instance, filtering + on ``'dict.sub_dict.foo'`` is allowed if + ``allowed_filters`` contains any of + ``'dict.sub_dict.foo``, ``'dict.sub_dict'`` + or ``'dict'``. Instead filtering on + ``'dict'`` is allowed if ``allowed_filters`` + contains ``'dict'``. + Can be set to ``[]`` (no filters allowed), or + ``['*']`` (fields allowed on every field). + Defaults to ``['*']``. -``mongo_write_concern`` A dictionary defining MongoDB write concern - settings for the endpoint datasource. All - standard write concern settings (w, wtimeout, j, - fsync) are supported. Defaults to ``{'w': 1}`` - which means 'do regular acknowledged writes' - (this is also the Mongo default.) - - Please be aware that setting 'w' to a value of - 2 or greater requires replication to be active - or you will be getting 500 errors (the write - will still happen; Mongo will just be unable - to check that it's being written to multiple - servers.) - -``mongo_prefix`` Allows overriding of the default ``MONGO`` - prefix, which is used when retrieving MongoDB - settings from configuration. - - For example if ``mongo_prefix`` is set to - ``MONGO2`` then, when serving requests for the - endpoint, ``MONGO2`` prefixed settings will - be used to access the database. - - This allows for eventually serving data from - a different database/server at every endpoint. - - See also: :ref:`authdrivendb`. - -``mongo_indexes`` Allows to specify a set of indexes to be - created for this resource before the app is - launched. - - Indexes are expressed as a dict where keys are - index names and values are either a list of - tuples of (field, direction) pairs, or - a tuple with a list of field/direction pairs - *and* index options expressed as a dict, such - as ``{'index name': [('field', 1)], 'index with - args': ([('field', 1)], {"sparse": True})}``. - - Multiple pairs are used to create compound - indexes. Direction takes all kind of values - supported by PyMongo, such as ``ASCENDING`` - = 1 and ``DESCENDING`` = -1. All index options - such as ``sparse``, ``min``, ``max``, - etc. are supported (see PyMongo_ documentation.) - - *Please note:* keep in mind that index design, - creation and maintenance is a very important - task and should be planned and executed with - great care. Usually it is also a very resource - intensive operation. You might therefore want - to handle this task manually, out of the - context of API instantiation. Also remember - that, by default, any already exsistent index - for which the definition has been changed, will - be dropped and re-created. - -``authentication`` A class with the authorization logic for the - endpoint. If not provided the eventual - general purpose auth class (passed as - application constructor argument) will be used. - For details on authentication and authorization - see :ref:`auth`. Defaults to ``None``, - -``embedded_fields`` A list of fields for which :ref:`embedded_docs` - is enabled by default. For this feature to work - properly fields in the list must be - ``embeddable``, and ``embedding`` must be - active for the resource. - -``query_objectid_as_string`` When enabled the Mongo parser will avoid - automatically casting electable strings to - ObjectIds. This can be useful in those rare - occurrences where you have string fields in the - database whose values can actually be casted to - ObjectId values, but shouldn't. It effects - queries (``?where=``) and parsing of payloads. - Defaults to ``False``. + *Please note:* If API scraping or DB DoS + attacks are a concern, then globally disabling + filters (see ``ALLOWED_FILTERS`` above) and + then whitelisting valid ones at the local level + is the way to go. + +``sorting`` ``True`` if sorting is enabled, ``False`` + otherwise. Locally overrides ``SORTING``. -``internal_resource`` When ``True``, this option makes the resource - internal. No HTTP action can be performed on - the endpoint, which is still accessible from - the Eve data layer. See - :ref:`internal_resources` for more - informations. Defaults to ``False``. +``pagination`` ``True`` if pagination is enabled, ``False`` + otherwise. Locally overrides ``PAGINATION``. -``etag_ignore_fields`` List of fields that - should not be used to compute the ETag value. - Defaults to ``None`` which means that by - default all fields are included in the computation. - It looks like ``['field1', 'field2', - 'field3.nested_field', ...]``. +``pagination_limit`` Maximum value allowed for ``QUERY_MAX_RESULTS`` + query parameter. Values exceeding the + limit will be silently replaced with this + value. You want to aim for a reasonable + compromise between performance and transfer + size. Defaults to 50. -``schema`` A dict defining the actual data structure being - handled by the resource. Enables data - validation. See `Schema Definition`_. +``resource_methods`` A list of HTTP methods supported at resource + endpoint. Allowed values: ``GET``, ``POST``, + ``DELETE``. Locally overrides + ``RESOURCE_METHODS``. + + *Please note:* if you're running version 0.0.5 + or earlier use the now unsupported ``methods`` + keyword instead. + +``public_methods`` A list of HTTP methods supported at resource + endpoint, open to public access even when + :ref:`auth` is enabled. Locally overrides + ``PUBLIC_METHODS``. + +``item_methods`` A list of HTTP methods supported at item + endpoint. Allowed values: ``GET``, ``PATCH``, + ``PUT`` and ``DELETE``. ``PATCH`` or, for + clients not supporting PATCH, ``POST`` with + the ``X-HTTP-Method-Override`` header tag. + Locally overrides ``ITEM_METHODS``. + +``public_item_methods`` A list of HTTP methods supported at item + endpoint, left open to public access when + :ref:`auth` is enabled. Locally overrides + ``PUBLIC_ITEM_METHODS``. + +``allowed_roles`` A list of allowed `roles` for resource + endpoint. See :ref:`auth` for more + information. Locally overrides + ``ALLOWED_ROLES``. + +``allowed_read_roles`` A list of allowed `roles` for resource + endpoint with GET and OPTIONS methods. + See :ref:`auth` for more + information. Locally overrides + ``ALLOWED_READ_ROLES``. + +``allowed_write_roles`` A list of allowed `roles` for resource + endpoint with POST, PUT and DELETE. + See :ref:`auth` for more + information. Locally overrides + ``ALLOWED_WRITE_ROLES``. + +``allowed_item_read_roles`` A list of allowed `roles` for item endpoint + with GET and OPTIONS methods. + See :ref:`auth` for more information. + Locally overrides ``ALLOWED_ITEM_READ_ROLES``. + + +``allowed_item_write_roles`` A list of allowed `roles` for item endpoint + with PUT, PATH and DELETE methods. + See :ref:`auth` for more information. + Locally overrides ``ALLOWED_ITEM_WRITE_ROLES``. + +``allowed_item_roles`` A list of allowed `roles` for item endpoint. + See :ref:`auth` for more information. + Locally overrides ``ALLOWED_ITEM_ROLES``. + +``cache_control`` Value of the ``Cache-Control`` header field + used when serving ``GET`` requests. Leave empty + if you don't want to include cache directives + with API responses. Locally overrides + ``CACHE_CONTROL``. + +``cache_expires`` Value (in seconds) of the ``Expires`` header + field used when serving ``GET`` requests. If + set to a non-zero value, the header will + always be included, regardless of the setting + of ``CACHE_CONTROL``. Locally overrides + ``CACHE_EXPIRES``. + +``id_field`` Field used to uniquely identify resource items + within the database. Locally overrides + ``ID_FIELD``. + +``item_lookup`` ``True`` if item endpoint should be available, + ``False`` otherwise. Locally overrides + ``ITEM_LOOKUP``. + +``item_lookup_field`` Field used when looking up a resource + item. Locally overrides ``ITEM_LOOKUP_FIELD``. + +``item_url`` Rule used to construct item endpoint URL. + Locally overrides ``ITEM_URL``. + +``resource_title`` Title used when building resource links + (HATEOAS). Defaults to resource's ``url``. + +``item_title`` Title to be used when building item references, + both in XML and JSON responses. Overrides + ``ITEM_TITLE``. + +``additional_lookup`` Besides the standard item endpoint which + defaults to ``//``, + you can optionally define a secondary, + read-only, endpoint like + ``//``. You do so by + defining a dictionary comprised of two items + `field` and `url`. The former is the name of + the field used for the lookup. If the field + type (as defined in the resource schema_) is + a string, then you put a URL rule in `url`. If + it is an integer, then you just omit `url`, as + it is automatically handled. See the code + snippet below for an usage example of this + feature. + +``datasource`` Explicitly links API resources to database + collections. See `Advanced Datasource + Patterns`_. + +``auth_field`` Enables :ref:`user-restricted`. When the + feature is enabled, users can only + read/update/delete resource items created by + themselves. The keyword contains the actual + name of the field used to store the id of + the user who created the resource item. Locally + overrides ``AUTH_FIELD``. + +``allow_unknown`` When ``True``, this option will allow insertion + of arbitrary, unknown fields to the endpoint. + Use with caution. Locally overrides + ``ALLOW_UNKNOWN``. See :ref:`unknown` for more + information. Defaults to ``False``. + +``projection`` When ``True``, this option enables the + :ref:`projections` feature. Locally overrides + ``PROJECTION``. Defaults to ``True``. + +``embedding`` When ``True`` this option enables the + :ref:`embedded_docs` feature. Defaults to + ``True``. + +``extra_response_fields`` Allows to configure a list of additional + document fields that should be provided with + every POST response. Normally only + automatically handled fields (``ID_FIELD``, + ``LAST_UPDATED``, ``DATE_CREATED``, ``ETAG``) + are included in response payloads. Overrides + ``EXTRA_RESPONSE_FIELDS``. + +``hateoas`` When ``False``, this option disables + :ref:`hateoas_feature` for the resource. + Defaults to ``True``. -``bulk_enabled`` When ``True`` this option enables the - :ref:`bulk_insert` feature for this resource. - Locally overrides ``BULK_ENABLED``. +``mongo_query_whitelist`` A list of extra Mongo query operators to allow + for this endpoint besides the official list of + allowed operators. Defaults to ``[]``. + +``mongo_write_concern`` A dictionary defining MongoDB write concern + settings for the endpoint datasource. All + standard write concern settings (w, wtimeout, j, + fsync) are supported. Defaults to ``{'w': 1}`` + which means 'do regular acknowledged writes' + (this is also the Mongo default.) + + Please be aware that setting 'w' to a value of + 2 or greater requires replication to be active + or you will be getting 500 errors (the write + will still happen; Mongo will just be unable + to check that it's being written to multiple + servers.) + +``mongo_prefix`` Allows overriding of the default ``MONGO`` + prefix, which is used when retrieving MongoDB + settings from configuration. + + For example if ``mongo_prefix`` is set to + ``MONGO2`` then, when serving requests for the + endpoint, ``MONGO2`` prefixed settings will + be used to access the database. + + This allows for eventually serving data from + a different database/server at every endpoint. + + See also: :ref:`authdrivendb`. + +``mongo_indexes`` Allows to specify a set of indexes to be + created for this resource before the app is + launched. + + Indexes are expressed as a dict where keys are + index names and values are either a list of + tuples of (field, direction) pairs, or + a tuple with a list of field/direction pairs + *and* index options expressed as a dict, such + as ``{'index name': [('field', 1)], 'index with + args': ([('field', 1)], {"sparse": True})}``. + + Multiple pairs are used to create compound + indexes. Direction takes all kind of values + supported by PyMongo, such as ``ASCENDING`` + = 1 and ``DESCENDING`` = -1. All index options + such as ``sparse``, ``min``, ``max``, + etc. are supported (see PyMongo_ documentation.) + + *Please note:* keep in mind that index design, + creation and maintenance is a very important + task and should be planned and executed with + great care. Usually it is also a very resource + intensive operation. You might therefore want + to handle this task manually, out of the + context of API instantiation. Also remember + that, by default, any already existent index + for which the definition has been changed, will + be dropped and re-created. + +``authentication`` A class with the authorization logic for the + endpoint. If not provided the eventual + general purpose auth class (passed as + application constructor argument) will be used. + For details on authentication and authorization + see :ref:`auth`. Defaults to ``None``, + +``embedded_fields`` A list of fields for which :ref:`embedded_docs` + is enabled by default. For this feature to work + properly fields in the list must be + ``embeddable``, and ``embedding`` must be + active for the resource. + +``query_objectid_as_string`` When enabled the Mongo parser will avoid + automatically casting electable strings to + ObjectIds. This can be useful in those rare + occurrences where you have string fields in the + database whose values can actually be casted to + ObjectId values, but shouldn't. It effects + queries (``?where=``) and parsing of payloads. + Defaults to ``False``. + +``internal_resource`` When ``True``, this option makes the resource + internal. No HTTP action can be performed on + the endpoint, which is still accessible from + the Eve data layer. See + :ref:`internal_resources` for more + information. Defaults to ``False``. + +``etag_ignore_fields`` List of fields that + should not be used to compute the ETag value. + Defaults to ``None`` which means that by + default all fields are included in the computation. + It looks like ``['field1', 'field2', + 'field3.nested_field', ...]``. + +``schema`` A dict defining the actual data structure being + handled by the resource. Enables data + validation. See `Schema Definition`_. + +``bulk_enabled`` When ``True`` this option enables the + :ref:`bulk_insert` feature for this resource. + Locally overrides ``BULK_ENABLED``. + +``soft_delete`` When ``True`` this option enables the + :ref:`soft_delete` feature for this resource. + Locally overrides ``SOFT_DELETE``. + +``merge_nested_documents`` If ``True``, updates to nested fields are + merged with the current data on ``PATCH``. + If ``False``, the updates overwrite the + current data. Locally overrides + ``MERGE_NESTED_DOCUMENTS``. +``normalize_dotted_fields`` If ``True``, dotted fields are parsed and + processed as subdocument fields. If ``False``, + dotted fields are left unparsed and + unprocessed, and the payload is passed to the + underlying data-layer as-is. Please note that + with the default Mongo layer, setting this to + ``False`` will result in an error. Defaults to + ``True``. +``normalize_on_patch`` If ``True``, the patch document will be + normalized according to schema. This means if + a field is not included in the patch body, it + will be reset to the default value in its + schema. If ``False``, the field which is not + included in the patch body will be kept + untouched. Defaults to ``True``. +``optimize_pagination_for_speed`` Set this to ``True`` to improve pagination + performance. When optimization is active no + count operation, which can be slow on large + collections, is performed on the database. + This does have a few consequences. + Firstly, no document count is returned. + Secondly, ``HATEOAS`` is less accurate: no + last page link is available, and next page + link is always included, even on last page. + On big collections, switching this feature + on can greatly improve performance. + Defaults to ``False`` (slower performance; + document count included; accurate + ``HATEOAS``). -``soft_delete`` When ``True`` this option enables the - :ref:`soft_delete` feature for this resource. - Locally overrides ``SOFT_DELETE``. =============================== =============================================== @@ -1073,7 +1233,7 @@ streams. :: # 'people' schema definition - 'schema'= { + schema = { 'firstname': { 'type': 'string', 'minlength': 1, @@ -1135,9 +1295,10 @@ defining the field validation rules. Allowed validation rules are: - ``polygon`` - ``multipolygon`` - ``geometrycollection`` + - ``decimal`` See :ref:`GeoJSON ` for more - informations geo fields. + information geo fields. ``required`` If ``True``, the field is mandatory on insertion. @@ -1193,9 +1354,22 @@ defining the field validation rules. Allowed validation rules are: If URRA is not active on the endpoint, this rule behaves like ``unique`` +``unique_within_resource`` The value of the field must be unique within + the resource. + + This differs from the ``unique`` rule in that + it will use the datasource filter when searching + for documents with the same value for the field. + Use this when the resource shares the database + collection with other resources but their documents + should not be taken into account when evaluating + the uniqueness of the field. When used in a resource + without datasource filter, this rule behaves like + ``unique``. + ``data_relation`` Allows to specify a referential integrity rule that the value must satisfy in order to - validate. It is a dict with three keys: + validate. It is a dict with four keys: - ``resource``: the name of the resource being referenced; - ``field``: the field name in the foreign resource; @@ -1262,28 +1436,27 @@ defining the field validation rules. Allowed validation rules are: ``valueschema`` Validation schema for all values of a ``dict``. The dict can have arbitrary keys, the values for all of which must validate with given - schema. See `valueschema example `_. + schema. See `valueschema `_ in Cerberus docs. -``propertyschema`` This is the counterpart to ``valueschema`` that +``keyschema`` This is the counterpart to ``valueschema`` that validates the keys of a dict. Validation schema for all values of a ``dict``. See - `propertyschema example - `_. + `keyschema `_ in Cerberus docs. ``regex`` Validation will fail if field value does not match the provided regex rule. Only applies to - string fields. See `email validation example `_ + string fields. See `regex `_ in Cerberus docs. ``dependencies`` This rule allows a list of fields that must be present in order for the target field to be - allowed. See `dependencies example `_ + allowed. See `dependencies `_ in Cerberus docs. ``anyof`` This rule allows you to list multiple sets of rules to validate against. The field will be considered valid if it validates against one - set in the list. See `anyof example `_ + set in the list. See `*of-rules `_ in Cerberus docs. ``allof`` Same as ``anyof``, except that all rule collections in the list must validate. @@ -1299,7 +1472,7 @@ defining the field validation rules. Allowed validation rules are: return value of the callable replaces the new value in the document. This can be used to convert values or sanitize data before it is - validated. See `type coercion example `_ + validated. See `value coercion `_ in Cerberus docs. =============================== ============================================== @@ -1337,7 +1510,7 @@ of the database collection. It is a dictionary with four allowed keys: ``filter`` Database query used to retrieve and validate data. If omitted, by default the whole - collection is retrievied. See :ref:`filter`. + collection is retrieved. See :ref:`filter`. ``projection`` Fieldset exposed by the endpoint. If omitted, by default all fields will be returned to the @@ -1351,7 +1524,7 @@ of the database collection. It is a dictionary with four allowed keys: ``'datasource': {'default_sort': [('name', 1)]}`` - For more informations on sort and filters see + For more information on sort and filters see :ref:`filters`. ``aggregation`` Aggregation pipeline and options. When used all @@ -1365,7 +1538,7 @@ of the database collection. It is a dictionary with four allowed keys: - ``pipeline``. The aggregation pipeline. Syntax must match the one supported by - PyMongo. For more informations see `PyMongo + PyMongo. For more information see `PyMongo Aggregation Examples`_ and the official `MongoDB Aggregation Framework`_ documentation. @@ -1442,6 +1615,14 @@ By default API responses to GET requests will include all fields defined by the corresponding resource schema_. The ``projection`` setting of the `datasource` resource keyword allows you to redefine the fieldset. +When you want to hide some *secret fields* from client, you should use +inclusive projection setting and include all fields should be exposed. While, +when you want to limit default responses to certain fields but still allow them +to be accessible through client-side projections, you should use exclusive +projection setting and exclude fields should be omitted. + +The following is an example for inclusive projection setting: + :: people = { @@ -1451,9 +1632,18 @@ resource keyword allows you to redefine the fieldset. } The above setting will expose only the `username` field to GET requests, no -matter the schema_ defined for the resource. +matter the schema_ defined for the resource. And other fields **will not** be +exposed even by client-side projection. The following API call will not return +`lastname` or `born`. + +.. code-block:: console + + $ curl -i http://myapi/people?projection={"lastname": 1, "born": 1} + HTTP/1.1 200 OK -Likewise, you can exclude fields from API responses: +You can also exclude fields from API responses. But this time, the excluded +fields **will be** exposed to client-side projection. The following is an +example for exclusive projection setting: :: @@ -1463,7 +1653,19 @@ Likewise, you can exclude fields from API responses: } } -The above will include all document fields but `username`. +The above will include all document fields but `username`. However, the +following API call will return `username` this time. Thus, you can exploit this +behaviour to serve media fields or other expensive fields. + +In most cases, none or inclusive projection setting is preferred. With +inclusive projection, secret fields are taken care from server side, and default +fields returned can be defined by short-cut functions from client-side. + +.. code-block:: console + + $ curl -i http://myapi/people?projection={"username": 1} + HTTP/1.1 200 OK + Please note that POST and PATCH methods will still allow the whole schema to be manipulated. This feature can come in handy when, for example, you want to @@ -1482,3 +1684,5 @@ read access open to the public. .. _`PyMongo Aggregation Examples`: http://api.mongodb.org/python/current/examples/aggregation.html#aggregation-framework .. _`MongoDB Aggregation Framework`: https://docs.mongodb.org/v3.0/applications/aggregation/ .. _`PyMongo aggregation defaults`: http://api.mongodb.org/python/current/api/pymongo/collection.html#pymongo.collection.Collection.aggregate +.. _`PyMongo Authentication Mechanisms`: https://docs.mongodb.com/v3.0/core/authentication-mechanisms/ +.. _`PyMongo mongo_client`: https://pymongo.readthedocs.io/en/stable/api/pymongo/mongo_client.html diff --git a/docs/extensions.rst b/docs/extensions.rst index dadbf5d6f..6a07f9d68 100644 --- a/docs/extensions.rst +++ b/docs/extensions.rst @@ -4,29 +4,52 @@ Extensions Welcome to the Eve extensions registry. Here you can find a list of packages that extend Eve. This list is moderated and updated on a regular basis. If you wrote a package for Eve and want it to show up here, just `get in touch`_ and -show me your tool! +show me your tool! +- Eve-Auth-JWT_ - Eve-Elastic_ +- Eve-Healthcheck_ +- Eve-Mocker_ - Eve-Mongoengine_ -- Eve-Swagger_ -- Eve-Docs_ +- Eve-Neo4j_ +- Eve-OAuth2_ and Flask-Sentinel_ - Eve-SQLAlchemy_ +- Eve-Swagger_ - Eve.NET_ -- Eve-OAuth2_ and Flask-Sentinel_ -- Eve-Auth-JWT_ - EveGenie_ -- Eve-Mocker_ -- Eve-Neo4j_ - - `REST Layer for Golang`_ +Eve-Auth-JWT +------------ + +| *by Olivier Poitrey* + +Eve-Auth-JWT_ is An OAuth 2 JWT token validation module for Eve. + Eve-Elastic ----------- | *by Petr Jašek* Eve-Elastic_ is an elasticsearch data layer for the Eve REST framework. -Features facets support and the generation of mapping for schema. +Features facets support and the generation of mapping for schema. + +Eve-Healthcheck +--------------- + +| *by LuisComS* + +Eve-Healthcheck_ is project that servers healthcheck urls used to monitor your +Eve application. + +Eve-Mocker +---------- +*by Thomas Sileo* + +`Eve-Mocker`_ is a mocking tool for Eve powered REST APIs, based on the +excellent HTTPretty, aimed to be used in your unit tests, when you rely on an +Eve API. Eve-Mocker has been featured on the Eve blog: `Mocking tool for Eve +APIs`_ Eve-Mongoengine --------------- @@ -37,7 +60,30 @@ Eve-Mongoengine_ is an Eve extension, which enables Mongoengine ORM models to be used as eve schema. If you use mongoengine in your application and simultaneously want to use Eve, instead of writing schema again in Cerberus format (DRY!), you can use this extension, which takes your mongoengine models -and auto-transforms them into Cerberus schema under the hood. +and auto-transforms them into Cerberus schema under the hood. + +Eve-Neo4j +--------- +*by Abraxas Biosystems* + +Eve-Neo4j_ is an Eve extension aiming to enable it's users to build and +deploy highly customizable, fully featured RESTful Web Services using Neo4j +as backend. Powered by Eve, Py2neo, flask-neo4j and good intentions. + +Eve-OAuth2 +---------- +*by Nicola Iarocci* + +Eve-OAuth2_ is not an extension per-se, but rather an example of how you can +leverage Flask-Sentinel_ to protect your API endpoints with OAuth2. + +Eve-SQLAlchemy +-------------- +*by Andrew Mleczko et al.* + +Powered by Eve, SQLAlchemy and good intentions Eve-SQLALchemy_ allows to +effortlessly build and deploy highly customizable, fully featured RESTful Web +Services with SQL-based backends. Eve-Swagger ----------- @@ -56,19 +102,6 @@ Swagger website: For more information, see also the `Meet Eve-Swagger`_ article. -Eve-Docs --------- - -| *by Charles Flynn* - -Eve-docs_ is a blueprint that generates documentation for Eve APIs in HTML and -JSON formats. Eve-docs creates the documentation from your existing Eve -configuration file, with no additional configuration required. - -.. note:: - Looks like the Eve-Docs project has been stagnant for a while. You might - want to consider Eve-Swagger_ as an alternative. - Eve.NET ------- *by Nicola Iarocci* @@ -81,52 +114,13 @@ as a portable library (PCL) and runs seamlessly on .NET4, Mono, Xamarin.iOS, Xamarin.Android, Windows Phone 8 and Windows 8. We use Eve.NET internally to power our iOS, Web and Windows applications. -Eve-SQLAlchemy --------------- -*by Andrew Mleczko et al.* - -Powered by Eve, SQLAlchemy and good intentions Eve-SQLALchemy_ allows to -effortlessly build and deploy highly customizable, fully featured RESTful Web -Services with SQL-based backends. - -Eve-OAuth2 ----------- -*by Nicola Iarocci* - -Eve-OAuth2_ is not an extension per-se, but rather an example of how you can -leverage Flask-Sentinel_ to protect your API endpoints with OAuth2. - -Eve-Auth-JWT ------------- - -| *by Olivier Poitrey* - -Eve-Auth-JWT_ is An OAuth 2 JWT token validation module for Eve. - EveGenie -------- -*by Erin Corson and Matt Tucker* +*by Erin Corson and Matt Tucker, maintained by David Zisky.* EveGenie_ is a tool for generating Eve schemas. It accepts a json document of one or more resources and provides you with a starting schema definition. -Eve-Mocker ----------- -*by Thomas Sileo* - -`Eve-Mocker`_ is a mocking tool for Eve powered REST APIs, based on the -excellent HTTPretty, aimed to be used in your unit tests, when you rely on an -Eve API. Eve-Mocker has been featured on the Eve blog: `Mocking tool for Eve -APIs`_ - -Eve-Neo4j ---------- -*by Abraxas Biosystems* - -Eve-Neo4j_ is an Eve extension aiming to enable it's users to build and -deploy highly customizable, fully featured RESTful Web Services using Neo4j -as backend. Powered by Eve, Py2neo, flask-neo4j and good intentions. - REST Layer for Golang --------------------- If you are into Golang, you should also check `REST Layer`_. Developed by @@ -138,21 +132,21 @@ Olivier Poitrey, a long time Eve contributor and sustainer. REST Layer is You can focus on your business logic now. +.. _Eve-Healthcheck: https://github.com/ateliedocodigo/eve-healthcheck .. _`Mocking tool for Eve APIs`: http://blog.python-eve.org/eve-mocker .. _`Auto generate API docs`: http://blog.python-eve.org/eve-docs .. _charlesflynn/eve-docs: https://github.com/charlesflynn/eve-docs .. _eve-mocker: https://github.com/tsileo/eve-mocker -.. _Eve-docs: https://github.com/charlesflynn/eve-docs .. _`get in touch`: mailto:eve@nicolaiarocci.com .. _Eve-Mongoengine: https://github.com/hellerstanislav/eve-mongoengine .. _Eve-Elastic: https://github.com/petrjasek/eve-elastic -.. _Eve.NET: https://github.com/nicolaiarocci/Eve.NET +.. _Eve.NET: https://github.com/pyeve/Eve.NET .. _Eve-SQLAlchemy: https://github.com/RedTurtle/eve-sqlalchemy -.. _Eve-OAuth2: https://github.com/nicolaiarocci/eve-oauth2 -.. _Flask-Sentinel: https://github.com/nicolaiarocci/flask-sentinel +.. _Eve-OAuth2: https://github.com/pyeve/eve-oauth2 +.. _Flask-Sentinel: https://github.com/pyeve/flask-sentinel .. _Eve-Auth-JWT: https://github.com/rs/eve-auth-jwt .. _`REST Layer`: https://github.com/rs/rest-layer -.. _EveGenie: https://github.com/newmediadenver/evegenie -.. _Eve-Swagger: https://github.com/nicolaiarocci/eve-swagger +.. _EveGenie: https://github.com/DavidZisky/evegenie +.. _Eve-Swagger: https://github.com/pyeve/eve-swagger .. _`Meet Eve-Swagger`: http://nicolaiarocci.com/announcing-eve-swagger/ .. _Eve-Neo4j: https://github.com/Abraxas-Biosystems/eve-neo4j diff --git a/docs/features.rst b/docs/features.rst index c3c7a11cb..deee78a32 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -1,8 +1,6 @@ Features ======== -Below is a list of main features that any EVE-powered APIs can expose. Most of -these features can be experienced live by consuming the Demo API (see -:ref:`demo`). +Below is a list of main features that any EVE-powered APIs can expose. Emphasis on REST ---------------- @@ -49,7 +47,7 @@ can customize the URIs though, so the API endpoint could become, say, .. code-block:: console - $ curl -i http://eve-demo.herokuapp.com/people + $ curl -i http://myapi.com/people HTTP/1.1 200 OK The response payload will look something like this: @@ -102,8 +100,8 @@ These additional fields are automatically handled by the API (clients don't need to provide them when adding/editing resources). The ``_meta`` field provides pagination data and will only be there if -:ref:`Pagination` has been enabled (it is by default) and there is at least one -document being returned. The ``_links`` list provides HATEOAS_ directives. +:ref:`Pagination` has been enabled (it is by default). The ``_links`` list +provides HATEOAS_ directives. .. _subresources: @@ -174,6 +172,42 @@ a simple resource endpoint the document lookup would happen on a single field: invoices/ + +Endpoints that supports sub-resources will have a specific behavior on +``DELETE`` operations. A ``DELETE`` to the following endpoint: + +:: + + people/51f63e0838345b6dcd7eabff/invoices + +would cause the deletion of all the documents that match follow query: + +:: + + {'contact_id': '51f63e0838345b6dcd7eabff'} + + +Therefore, for sub-resource endpoints, only the documents satisfying the +endpoint semantic will be deleted. This differs from the standard behavior, +whereas a delete operation on a collection enpoint will cause the deletion of +all the documents in the collection. + +Another example. A ``DELETE`` to the following item endpoint: + +:: + + people/51f63e0838345b6dcd7eabff/invoices/1 + +would cause the deletion all the documents matched by the follow query: + +:: + + {'contact_id': '51f63e0838345b6dcd7eabff', "": 1} + +This behaviour enables support for typical tree structures, where the id of the +resource alone is not necessarily a primary key by itself. + + .. _custom_item_endpoints: Customizable, multiple item endpoints @@ -187,7 +221,7 @@ primary endpoint and will match your database primary key structure (i.e., an .. code-block:: console - $ curl -i http://eve-demo.herokuapp.com/people/521d6840c437dc0002d1203c + $ curl -i http://myapi.com/people/521d6840c437dc0002d1203c HTTP/1.1 200 OK Etag: 28995829ee85d69c4c18d597a0f68ae606a266cc Last-Modified: Wed, 21 Nov 2012 16:04:56 GMT @@ -198,7 +232,7 @@ will retrieve only the first match anyway. .. code-block:: console - $ curl -i http://eve-demo.herokuapp.com/people/Doe + $ curl -i http://myapi.com/people/Doe HTTP/1.1 200 OK Etag: 28995829ee85d69c4c18d597a0f68ae606a266cc Last-Modified: Wed, 21 Nov 2012 16:04:56 GMT @@ -252,26 +286,26 @@ Here we are asking for all documents where ``lastname`` value is ``Doe``: :: - http://eve-demo.herokuapp.com/people?where={"lastname": "Doe"} + http://myapi.com/people?where={"lastname": "Doe"} With ``curl`` you would go like this: .. code-block:: console - $ curl -i -g http://eve-demo.herokuapp.com/people?where={%22lastname%22:%20%22Doe%22} + $ curl -i -g http://myapi.com/people?where={%22lastname%22:%20%22Doe%22} HTTP/1.1 200 OK Filtering on embedded document fields is possible: :: - http://eve-demo.herokuapp.com/people?where={"location.city": "San Francisco"} + http://myapi.com/people?where={"location.city": "San Francisco"} Date fields are also easy to query on: :: - http://eve-demo.herokuapp.com/people?where={"born": {"$gte":"Wed, 25 Feb 1987 17:00:00 GMT"}} + http://myapi.com/people?where={"born": {"$gte":"Wed, 25 Feb 1987 17:00:00 GMT"}} Date values should conform to RFC1123. Should you need a different format, you can change the ``DATE_FORMAT`` setting. @@ -282,11 +316,11 @@ Native Python syntax works like this: .. code-block:: console - $ curl -i http://eve-demo.herokuapp.com/people?where=lastname=="Doe" + $ curl -i http://myapi.com/people?where=lastname=="Doe" HTTP/1.1 200 OK Both syntaxes allow for conditional and logical And/Or operators, however -nested and combined. +nested and combined. Filters are enabled by default on all document fields. However, the API maintainer can choose to disable them all and/or whitelist allowed ones (see @@ -296,7 +330,57 @@ filters is the way to go. You also have the option to validate the incoming filters against the resource's schema and refuse to apply the filtering if any filters are invalid, by using the -``VALIDATE_FILTERING`` system setting (see :ref:`global`) +``VALIDATE_FILTERING`` system setting (see :ref:`global`). + +.. warning:: + + **Security:** Since Eve exposes MongoDB's query operators to API consumers, + care must be taken when deciding which fields are filterable. With the default + configuration (``ALLOWED_FILTERS = ['*']``), comparison operators such as + ``$gt``, ``$lt``, and ``$ne`` can be used to blindly enumerate values of any + field, including sensitive ones like password hashes or tokens. For production + APIs, you should restrict ``ALLOWED_FILTERS`` to only the fields that are + intended to be queryable, and never allow filtering on fields that contain + secrets or credentials. Eve also blacklists dangerous operators like ``$where`` + and ``$regex`` by default via ``MONGO_QUERY_BLACKLIST`` (see :ref:`global`). + +Pretty Printing +--------------- +You can pretty print the response by specifying a query parameter named +``pretty``: + +.. code-block:: console + + $ curl -i http://myapi.com/people?pretty + HTTP/1.1 200 OK + + { + "_items": [ + { + "_updated": "Tue, 19 Apr 2016 08:19:00 GMT", + "firstname": "John", + "lastname": "Doe", + "born": "Thu, 27 Aug 1970 14:37:13 GMT", + "role": [ + "author" + ], + "location": { + "city": "Auburn", + "address": "422 South Gay Street" + }, + "_links": { + "self": { + "href": "people/5715e9f438345b3510d27eb8", + "title": "person" + } + }, + "_created": "Tue, 19 Apr 2016 08:19:00 GMT", + "_id": "5715e9f438345b3510d27eb8", + "_etag": "86dc6b45fe7e2f41f1ca53a0e8fda81224229799" + }, + ... + ] + } Sorting @@ -305,7 +389,7 @@ Sorting is supported as well: .. code-block:: console - $ curl -i http://eve-demo.herokuapp.com/people?sort=city,-lastname + $ curl -i http://myapi.com/people?sort=city,-lastname HTTP/1.1 200 OK Would return documents sorted by city and then by lastname (descending). As you @@ -316,13 +400,13 @@ The MongoDB data layer also supports native MongoDB syntax: :: - http://eve-demo.herokuapp.com/people?sort=[("lastname", -1)] + http://myapi.com/people?sort=[("lastname", -1)] which translates to the following ``curl`` request: .. code-block:: console - $ curl -i http://eve-demo.herokuapp.com/people?sort=[(%22lastname%22,%20-1)] + $ curl -i http://myapi.com/people?sort=[(%22lastname%22,%20-1)] HTTP/1.1 200 OK Would return documents sorted by lastname in descending order. @@ -349,14 +433,14 @@ consumers can request specific pages via the query string: .. code-block:: console - $ curl -i http://eve-demo.herokuapp.com/people?max_results=20&page=2 + $ curl -i http://myapi.com/people?max_results=20&page=2 HTTP/1.1 200 OK Of course you can mix all the available query parameters: .. code-block:: console - $ curl -i http://eve-demo.herokuapp.com/people?where={"lastname": "Doe"}&sort=[("firstname", 1)]&page=5 + $ curl -i http://myapi.com/people?where={"lastname": "Doe"}&sort=[("firstname", 1)]&page=5 HTTP/1.1 200 OK Pagination can be disabled. Please note that, for clarity, the above example is @@ -404,7 +488,7 @@ HATEOAS links are always relative to the API entry point, so if your API home is at ``examples.com/api/v1``, the ``self`` link in the above example would mean that the *people* endpoint is located at ``examples.com/api/v1/people``. -Please note that ``next``, ``previous`` and ``last`` items will only be +Please note that ``next``, ``previous``, ``last`` and ``related`` items will only be included when appropriate. Disabling HATEOAS @@ -414,17 +498,17 @@ want to turn HATEOAS off? Well, if you know that your client application is not going to use the feature, then you might want to save on both bandwidth and performance. -.. _jsonxml: +.. _rendering: -JSON and XML Rendering ----------------------- +Rendering +--------- Eve responses are automatically rendered as JSON (the default) or XML, depending on the request ``Accept`` header. Inbound documents (for inserts and edits) are in JSON format. .. code-block:: console - $ curl -H "Accept: application/xml" -i http://eve-demo.herokuapp.com + $ curl -H "Accept: application/xml" -i http://myapi.com HTTP/1.1 200 OK Content-Type: application/xml; charset=utf-8 ... @@ -436,10 +520,18 @@ edits) are in JSON format. -XML support can be disabled by setting ``XML`` to ``False`` in the settings -file. JSON support can be disabled by setting ``JSON`` to ``False``. Please -note that at least one mime type must always be enabled, either implicitly or -explicitly. By default, both are supported. +Default renderers might be changed by editing ``RENDERERS`` value in the settings file. + +.. code-block:: python + + RENDERERS = [ + 'eve.render.JSONRenderer', + 'eve.render.XMLRenderer' + ] + +You can create your own renderer by subclassing ``eve.render.Renderer``. Each +renderer should set valid ``mime`` attr and have ``.render()`` method implemented. +Please note that at least one renderer must always be enabled. .. _conditional_requests: @@ -452,14 +544,14 @@ conditional requests by using the ``If-Modified-Since`` header: .. code-block:: console - $ curl -H "If-Modified-Since: Wed, 05 Dec 2012 09:53:07 GMT" -i http://eve-demo.herokuapp.com/people/521d6840c437dc0002d1203c + $ curl -H "If-Modified-Since: Wed, 05 Dec 2012 09:53:07 GMT" -i http://myapi.com/people/521d6840c437dc0002d1203c HTTP/1.1 200 OK or the ``If-None-Match`` header: .. code-block:: console - $ curl -H "If-None-Match: 1234567890123456789012345678901234567890" -i http://eve-demo.herokuapp.com/people/521d6840c437dc0002d1203c + $ curl -H "If-None-Match: 1234567890123456789012345678901234567890" -i http://myapi.com/people/521d6840c437dc0002d1203c HTTP/1.1 200 OK @@ -478,7 +570,7 @@ Consider the following workflow: .. code-block:: console - $ curl -H "Content-Type: application/json" -X PATCH -i http://eve-demo.herokuapp.com/people/521d6840c437dc0002d1203c -d '{"firstname": "ronald"}' + $ curl -H "Content-Type: application/json" -X PATCH -i http://myapi.com/people/521d6840c437dc0002d1203c -d '{"firstname": "ronald"}' HTTP/1.1 428 PRECONDITION REQUIRED We attempted an edit (``PATCH``), but we did not provide an ``ETag`` for the @@ -486,7 +578,7 @@ item so we got a ``428 PRECONDITION REQUIRED`` back. Let's try again: .. code-block:: console - $ curl -H "If-Match: 1234567890123456789012345678901234567890" -H "Content-Type: application/json" -X PATCH -i http://eve-demo.herokuapp.com/people/521d6840c437dc0002d1203c -d '{"firstname": "ronald"}' + $ curl -H "If-Match: 1234567890123456789012345678901234567890" -H "Content-Type: application/json" -X PATCH -i http://myapi.com/people/521d6840c437dc0002d1203c -d '{"firstname": "ronald"}' HTTP/1.1 412 PRECONDITION FAILED What went wrong this time? We provided the mandatory ``If-Match`` header, but @@ -495,7 +587,7 @@ currently stored on the server, so we got a ``412 PRECONDITION FAILED``. Again! .. code-block:: console - $ curl -H "If-Match: 80b81f314712932a4d4ea75ab0b76a4eea613012" -H "Content-Type: application/json" -X PATCH -i http://eve-demo.herokuapp.com/people/50adfa4038345b1049c88a37 -d '{"firstname": "ronald"}' + $ curl -H "If-Match: 80b81f314712932a4d4ea75ab0b76a4eea613012" -H "Content-Type: application/json" -X PATCH -i http://myapi.com/people/50adfa4038345b1049c88a37 -d '{"firstname": "ronald"}' HTTP/1.1 200 OK Finally! And the response payload looks something like this: @@ -526,7 +618,7 @@ control is disabled no ETag is provided with responses. You should be careful about disabling this feature, as you would effectively open your API to the risk of older versions replacing your documents. Alternatively, ETag match checks can be made optional by the client if ``ENFORCE_IF_MATCH`` is disabled. -When concurrenncy check enforcement is disabled, requests with the ``If-Match`` +When concurrency check enforcement is disabled, requests with the ``If-Match`` header will be processed as conditional requests, and requests made without the ``If-Match`` header will not be processed as conditional. @@ -538,7 +630,7 @@ A client may submit a single document for insertion: .. code-block:: console - $ curl -d '{"firstname": "barack", "lastname": "obama"}' -H 'Content-Type: application/json' http://eve-demo.herokuapp.com/people + $ curl -d '{"firstname": "barack", "lastname": "obama"}' -H 'Content-Type: application/json' http://myapi.com/people HTTP/1.1 201 OK In this case the response payload will just contain the relevant document @@ -554,13 +646,17 @@ metadata: "_links": {"self": {"href": "people/50ae43339fa12500024def5b", "title": "person"}} } -However, in order to reduce the number of loopbacks, a client might also submit +When a ``201 Created`` is returned following a POST request, the ``Location`` +header is also included with the response. Its value is the URI to the new +document. + +In order to reduce the number of loopbacks, a client might also submit multiple documents with a single request. All it needs to do is enclose the documents in a JSON list: .. code-block:: console - $ curl -d '[{"firstname": "barack", "lastname": "obama"}, {"firstname": "mitt", "lastname": "romney"}]' -H 'Content-Type: application/json' http://eve-demo.herokuapp.com/people + $ curl -d '[{"firstname": "barack", "lastname": "obama"}, {"firstname": "mitt", "lastname": "romney"}]' -H 'Content-Type: application/json' http://myapi.com/people HTTP/1.1 201 OK The response will be a list itself, with the state of each document: @@ -588,10 +684,14 @@ The response will be a list itself, with the state of each document: } When multiple documents are submitted the API takes advantage of MongoDB *bulk -insert* capabilities which means that not only there's just one single request -traveling from the client to the remote API, but also that only one loopback is +insert* capabilities which means that not only there's just one request +traveling from the client to the remote API, but also that a single loopback is performed between the API server and the database. +In case of successful multiple inserts, keep in mind that the ``Location`` +header only returns the URI of the first created document. + + Data Validation --------------- Data validation is provided out-of-the-box. Your configuration includes @@ -601,7 +701,7 @@ will only be updated if validation passes. .. code-block:: console - $ curl -d '[{"firstname": "bill", "lastname": "clinton"}, {"firstname": "mitt", "lastname": "romney"}]' -H 'Content-Type: application/json' http://eve-demo.herokuapp.com/people + $ curl -d '[{"firstname": "bill", "lastname": "clinton"}, {"firstname": "mitt", "lastname": "romney"}]' -H 'Content-Type: application/json' http://myapi.com/people HTTP/1.1 201 OK The response will contain a success/error state for each item provided in the @@ -644,6 +744,89 @@ a matter of fact, Eve's MongoDB data-layer itself extends Cerberus validation by implementing the ``unique`` schema field constraint. For more information see :ref:`validation`. +Editing a Document (PATCH) +-------------------------- +Clients can edit a document with the ``PATCH`` method, while ``PUT`` will +replace it. ``PATCH`` cannot remove a field, but only update its value. + +Consider the following schema: + +.. code-block:: javascript + + 'entity': { + 'name': { + 'type': 'string', + 'required': True + }, + 'contact': { + 'type': 'dict', + 'required': True, + 'schema': { + 'phone': { + 'type': 'string', + 'required': False, + 'default': '1234567890' + }, + 'email': { + 'type': 'string', + 'required': False, + 'default': 'abc@efg.com' + }, + } + } + } + + +Two notations: ``{contact: {email: 'an email'}}`` and ``{contact.email: 'an +email'}`` can be used to update the ``email`` field in the ``contact`` +subdocument. + +Keep in mind that ``PATCH`` cannot remove a field, but only update existing +values. Also, by default ``PATCH`` will normalize missing body fields that +have default values defined in the schema. Consider the schema above. If your +``PATCH`` has a body like this: + +:: + + {'contact.email': 'xyz@gmail.com'} + +and targets this document: + +:: + + { + 'name': 'test account', + 'contact': {'email': '123@yahoo.com', 'phone': '9876543210'} + } + +Then the updated document will look like this: + +:: + + { + 'name': 'test account', + 'contact': { + 'email': 'xyz@gmail.com', + 'phone': '1234567890' + } + } + +That is, ``contact.phone`` has been reset to its default value. This might +not been the desired behavior. To change it, you can set +``normalize_on_patch`` (or ``NORMALIZE_ON_PATCH`` globally) to ``False``. +Now the updated document will look like this: + +:: + + { + 'name': 'test account', + 'contact': { + 'email': '123@yahoo.com', + 'phone': '9876543210' + } + } + + .. _cache_control: Resource-level Cache Control @@ -652,7 +835,7 @@ You can set global and individual cache-control directives for each resource. .. code-block:: console - $ curl -i http://eve-demo.herokuapp.com + $ curl -i http://myapi HTTP/1.1 200 OK Content-Type: application/json Content-Length: 131 @@ -743,11 +926,17 @@ Eve-powered APIs can be accessed by the JavaScript contained in web pages. Disabled by default, CORS_ allows web pages to work with REST APIs, something that is usually restricted by most browsers 'same domain' security policy. The ``X_DOMAINS`` setting allows to specify which domains are allowed to perform -CORS requests. Regexes are also allowed, which is useful for websites with -dynamic ranges of subdomains. +CORS requests. A list of regular expressions may be defined in ``X_DOMAINS_RE``, which is useful for websites with dynamic ranges of subdomains. Make sure to +anchor and escape the regexes properly, for example +``X_DOMAINS_RE = ['^http://sub-\d{3}\.example\.com$']``. JSONP Support ------------- +.. deprecated:: + JSONP support is deprecated and will be removed in a future release. + Use CORS (Cross-Origin Resource Sharing) instead, which is supported by + all modern browsers and does not carry the security risks inherent to JSONP. + In general you don't really want to add JSONP when you can enable CORS instead: There have been some criticisms raised about JSONP. Cross-origin resource @@ -780,7 +969,7 @@ Default and Nullable Values --------------------------- Fields can have default values and nullable types. When serving POST (create) requests, missing fields will be assigned the configured default values. See -``default`` and ``nullable`` keywords in :ref:`schema` for more informations. +``default`` and ``nullable`` keywords in :ref:`schema` for more information. Predefined Database Filters --------------------------- @@ -806,7 +995,7 @@ where the client dictates which fields should be returned by the API. .. code-block:: console - $ curl -i http://eve-demo.herokuapp.com/people?projection={"lastname": 1, "born": 1} + $ curl -i -G http://myapi.com/people --data-urlencode 'projection={"lastname": 1, "born": 1}' HTTP/1.1 200 OK The query above will only return *lastname* and *born* out of all the fields @@ -814,7 +1003,7 @@ available in the 'people' resource. You can also exclude fields: .. code-block:: console - $ curl -i http://eve-demo.herokuapp.com/people?projection={"born": 0} + $ curl -i -G http://myapi.com/people --data-urlencode 'projection={"born": 0}' HTTP/1.1 200 OK The above will return all fields but *born*. Please note that key fields such @@ -953,6 +1142,12 @@ individually configured at the resource level using the domain configuration ``soft_delete`` setting. See :ref:`global` and :ref:`domain` for more information on enabling and configuring soft delete. +When soft deletion is enabled, callbacks attached to +``on_delete_resource_originals`` and +``on_delete_resource_originals_`` events will receive both +deleted and not deleted documents via the ``originals`` argument (see +:ref:`eventhooks`). + Behavior ~~~~~~~~ With soft delete enabled, DELETE requests to individual items and resources @@ -1061,10 +1256,10 @@ You can subscribe to these events with multiple callback functions. .. code-block:: pycon >>> def pre_get_callback(resource, request, lookup): - ... print 'A GET request on the "%s" endpoint has just been received!' % resource + ... print('A GET request on the "%s" endpoint has just been received!' % resource) >>> def pre_contacts_get_callback(request, lookup): - ... print 'A GET request on the contacts endpoint has just been received!' + ... print('A GET request on the contacts endpoint has just been received!') >>> app = Eve() @@ -1112,10 +1307,10 @@ payload. .. code-block:: pycon >>> def post_get_callback(resource, request, payload): - ... print 'A GET on the "%s" endpoint was just performed!' % resource + ... print('A GET on the "%s" endpoint was just performed!' % resource) >>> def post_contacts_get_callback(request, payload): - ... print 'A get on "contacts" was just performed!' + ... print('A get on "contacts" was just performed!') >>> app = Eve() @@ -1159,81 +1354,93 @@ both. And for each action two events will be fired: Let's see an overview of what events are available: -+-------+--------+------+-------------------------------------------------+ -|Action |What |When |Event name / method signature | -+=======+========+======+=================================================+ -|Fetch |Resource|After || ``on_fetched_resource`` | -| | | || ``def event(resource_name, response)`` | -| | | +-------------------------------------------------+ -| | | || ``on_fetched_resource_`` | -| | | || ``def event(response)`` | -| +--------+------+-------------------------------------------------+ -| |Item |After || ``on_fetched_item`` | -| | | || ``def event(resource_name, response)`` | -| | | +-------------------------------------------------+ -| | | || ``on_fetched_item_`` | -| | | || ``def event(response)`` | -+-------+--------+------+-------------------------------------------------+ -|Insert |Items |Before|| ``on_insert`` | -| | | || ``def event(resource_name, items)`` | -| | | +-------------------------------------------------+ -| | | || ``on_insert_`` | -| | | || ``def event(items)`` | -| | +------+-------------------------------------------------+ -| | |After || ``on_inserted`` | -| | | || ``def event(resource_name, items)`` | -| | | +-------------------------------------------------+ -| | | || ``on_inserted_`` | -| | | || ``def event(items)`` | -+-------+--------+------+-------------------------------------------------+ -|Replace|Item |Before|| ``on_replace`` | -| | | || ``def event(resource_name, item, original)`` | -| | | +-------------------------------------------------+ -| | | || ``on_replace_`` | -| | | || ``def event(item, original)`` | -| | +------+-------------------------------------------------+ -| | |After || ``on_replaced`` | -| | | || ``def event(resource_name, item, original)`` | -| | | +-------------------------------------------------+ -| | | || ``on_replaced_`` | -| | | || ``def event(item, original)`` | -+-------+--------+------+-------------------------------------------------+ -|Update |Item |Before|| ``on_update`` | -| | | || ``def event(resource_name, updates, original)``| -| | | +-------------------------------------------------+ -| | | || ``on_update_`` | -| | | || ``def event(updates, original)`` | -| | +------+-------------------------------------------------+ -| | |After || ``on_updated`` | -| | | || ``def event(resource_name, updates, original)``| -| | | +-------------------------------------------------+ -| | | || ``on_updated_`` | -| | | || ``def event(updates, original)`` | -+-------+--------+------+-------------------------------------------------+ -|Delete |Item |Before|| ``on_delete_item`` | -| | | || ``def event(resource_name, item)`` | -| | | +-------------------------------------------------+ -| | | || ``on_delete_item_`` | -| | | || ``def event(item)`` | -| | +------+-------------------------------------------------+ -| | |After || ``on_deleted_item`` | -| | | || ``def event(resource_name, item)`` | -| | | +-------------------------------------------------+ -| | | || ``on_deleted_item_`` | -| | | || ``def event(item)`` | -| +--------+------+-------------------------------------------------+ -| |Resource|Before|| ``on_delete_resource`` | -| | | || ``def event(resource_name, item)`` | -| | | +-------------------------------------------------+ -| | | || ``on_delete_resource_`` | -| | | || ``def event(item)`` | -| | +------+-------------------------------------------------+ -| | |After || ``on_deleted_resource`` | -| | | || ``def event(resource_name, item)`` | -| | | +-------------------------------------------------+ -| | | || ``on_deleted_resource_`` | -| | | || ``def event(item)`` | -+-------+--------+------+-------------------------------------------------+ ++-------+--------+------+--------------------------------------------------+ +|Action |What |When |Event name / method signature | ++=======+========+======+==================================================+ +|Fetch |Resource|After || ``on_fetched_resource`` | +| | | || ``def event(resource_name, response)`` | +| | | +--------------------------------------------------+ +| | | || ``on_fetched_resource_`` | +| | | || ``def event(response)`` | +| +--------+------+--------------------------------------------------+ +| |Item |After || ``on_fetched_item`` | +| | | || ``def event(resource_name, response)`` | +| | | +--------------------------------------------------+ +| | | || ``on_fetched_item_`` | +| | | || ``def event(response)`` | +| +--------+------+--------------------------------------------------+ +| |Diffs |After || ``on_fetched_diffs`` | +| | | || ``def event(resource_name, response)`` | +| | | +--------------------------------------------------+ +| | | || ``on_fetched_diffs_`` | +| | | || ``def event(response)`` | ++-------+--------+------+--------------------------------------------------+ +|Insert |Items |Before|| ``on_insert`` | +| | | || ``def event(resource_name, items)`` | +| | | +--------------------------------------------------+ +| | | || ``on_insert_`` | +| | | || ``def event(items)`` | +| | +------+--------------------------------------------------+ +| | |After || ``on_inserted`` | +| | | || ``def event(resource_name, items)`` | +| | | +--------------------------------------------------+ +| | | || ``on_inserted_`` | +| | | || ``def event(items)`` | ++-------+--------+------+--------------------------------------------------+ +|Replace|Item |Before|| ``on_replace`` | +| | | || ``def event(resource_name, item, original)`` | +| | | +--------------------------------------------------+ +| | | || ``on_replace_`` | +| | | || ``def event(item, original)`` | +| | +------+--------------------------------------------------+ +| | |After || ``on_replaced`` | +| | | || ``def event(resource_name, item, original)`` | +| | | +--------------------------------------------------+ +| | | || ``on_replaced_`` | +| | | || ``def event(item, original)`` | ++-------+--------+------+--------------------------------------------------+ +|Update |Item |Before|| ``on_update`` | +| | | || ``def event(resource_name, updates, original)`` | +| | | +--------------------------------------------------+ +| | | || ``on_update_`` | +| | | || ``def event(updates, original)`` | +| | +------+--------------------------------------------------+ +| | |After || ``on_updated`` | +| | | || ``def event(resource_name, updates, original)`` | +| | | +--------------------------------------------------+ +| | | || ``on_updated_`` | +| | | || ``def event(updates, original)`` | ++-------+--------+------+--------------------------------------------------+ +|Delete |Item |Before|| ``on_delete_item`` | +| | | || ``def event(resource_name, item)`` | +| | | +--------------------------------------------------+ +| | | || ``on_delete_item_`` | +| | | || ``def event(item)`` | +| | +------+--------------------------------------------------+ +| | |After || ``on_deleted_item`` | +| | | || ``def event(resource_name, item)`` | +| | | +--------------------------------------------------+ +| | | || ``on_deleted_item_`` | +| | | || ``def event(item)`` | +| +--------+------+--------------------------------------------------+ +| |Resource|Before|| ``on_delete_resource`` | +| | | || ``def event(resource_name)`` | +| | | +--------------------------------------------------+ +| | | || ``on_delete_resource_`` | +| | | || ``def event()`` | +| | | +--------------------------------------------------+ +| | | || ``on_delete_resource_originals`` | +| | | || ``def event(resource_name, originals, lookup)`` | +| | | +--------------------------------------------------+ +| | | || ``on_delete_resource_originals_``| +| | | || ``def event(originals, lookup)`` | +| | +------+--------------------------------------------------+ +| | |After || ``on_deleted_resource`` | +| | | || ``def event(resource_name)`` | +| | | +--------------------------------------------------+ +| | | || ``on_deleted_resource_`` | +| | | || ``def event()`` | ++-------+--------+------+--------------------------------------------------+ @@ -1246,6 +1453,8 @@ These are the fetch events with their method signature: - ``on_fetched_resource_(response)`` - ``on_fetched_item(resource_name, response)`` - ``on_fetched_item_(response)`` +- ``on_fetched_diffs(resource_name, response)`` +- ``on_fetched_diffs_(response)`` They are raised when items have just been read from the database and are about to be sent to the client. Registered callback functions can manipulate @@ -1254,27 +1463,29 @@ the items as needed before they are returned to the client. .. code-block:: pycon >>> def before_returning_items(resource_name, response): - ... print 'About to return items from "%s" ' % resource_name + ... print('About to return items from "%s" ' % resource_name) >>> def before_returning_contacts(response): - ... print 'About to return contacts' + ... print('About to return contacts') >>> def before_returning_item(resource_name, response): - ... print 'About to return an item from "%s" ' % resource_name + ... print('About to return an item from "%s" ' % resource_name) >>> def before_returning_contact(response): - ... print 'About to return a contact' + ... print('About to return a contact') >>> app = Eve() >>> app.on_fetched_resource += before_returning_items >>> app.on_fetched_resource_contacts += before_returning_contacts >>> app.on_fetched_item += before_returning_item - >>> app.on_fetched_item_contact += before_returning_contact + >>> app.on_fetched_item_contacts += before_returning_contact -It is important to note that fetch events will work with `Document -Versioning`_ for specific document versions or accessing all document -versions with ``?version=all``, but they *will not* work when acessing diffs -of all versions with ``?version=diffs``. +It is important to note that item fetch events will work with `Document +Versioning`_ for specific document versions like ``?version=5`` and all +document versions with ``?version=all``. Accessing diffs of all versions +with ``?version=diffs`` will only work with the diffs fetch events. Note +that diffs returns partial documents which should be handled in the +callback. Insert Events @@ -1288,7 +1499,7 @@ These are the insert events with their method signature: - ``on_inserted_(items)`` When a POST requests hits the API and new items are about to be stored in -the database, these vents are fired: +the database, these events are fired: - ``on_insert`` for every resource endpoint. - ``on_insert_`` for the specific `` resource @@ -1313,10 +1524,10 @@ Example: .. code-block:: pycon >>> def before_insert(resource_name, items): - ... print 'About to store items to "%s" ' % resource + ... print('About to store items to "%s" ' % resource_name) >>> def after_insert_contacts(items): - ... print 'About to store contacts' + ... print('About to store contacts') >>> app = Eve() >>> app.on_insert += before_insert @@ -1347,7 +1558,7 @@ accessory action. After the item has been replaced, these other two events are fired: - ``on_replaced`` for any resource item endpoint. -- ``on_replaced_`` for the specific resource endpont. +- ``on_replaced_`` for the specific resource endpoint. Update Events ^^^^^^^^^^^^^ @@ -1394,6 +1605,8 @@ These are the delete events with their method signature: - ``on_deleted_item_(item)`` - ``on_delete_resource(resource_name)`` - ``on_delete_resource_()`` +- ``on_delete_resource_originals(originals, lookup)`` +- ``on_delete_resource_originals_(originals, lookup)`` - ``on_deleted_resource(resource_name)`` - ``on_deleted_resource_()`` @@ -1424,6 +1637,47 @@ notified of such a disastrous occurrence by hooking a callback function to the ``on_delete_resource(resource_name)`` or ``on_delete_resource_()`` hooks. +- ``on_delete_resource_originals`` for any resource hit by the request after having retrieved the originals documents. +- ``on_delete_resource_originals_`` for the specific `` resource endpoint + hit by the DELETE after having retrieved the original document. + +NOTE: those two event are useful in order to perform some business +logic before the actual remove operation given the look up and the +list of originals + +.. _aggregation_hooks: + +Aggregation event hooks +~~~~~~~~~~~~~~~~~~~~~~~ +You can also attach one or more callbacks to your aggregation endpoints. The +``before_aggregation`` event is fired when an aggregation is about to be +performed. Any attached callback function will receive both the endpoint name +and the aggregation pipeline as arguments. The pipeline can then be altered if +needed. + +.. code-block:: pycon + + >>> def on_aggregate(endpoint, pipeline): + ... pipeline.append({"$unwind": "$tags"}) + + >>> app = Eve() + >>> app.before_aggregation += on_aggregate + +The ``after_aggregation`` event is fired when the aggregation has been +performed. An attached callback function could leverage this event to modify +the documents before they are returned to the client. + +.. code-block:: pycon + + >>> def alter_documents(endpoint, documents): + ... for document in documents: + ... document['hello'] = 'well, hello!' + + >>> app = Eve() + >>> app.after_aggregation += alter_documents + +For more information on aggregation support, see :ref:`aggregation` + .. admonition:: Please note @@ -1467,7 +1721,7 @@ File Storage ------------ Media files (images, pdf, etc.) can be uploaded as ``media`` document fields. Upload is done via ``POST``, ``PUT`` and -``PATCH`` as usual, but using the ``multipart/data-form`` content-type. +``PATCH`` as usual, but using the ``multipart/form-data`` content-type. Let us assume that the ``accounts`` endpoint has a schema like this: @@ -1579,6 +1833,31 @@ set your media endpoint like so: Setting ``MEDIA_BASE_URL`` is optional. If no value is set, then the API base address will be used when building the URL for ``MEDIA_ENDPOINT``. +.. _partial_request: + +Partial media downloads +~~~~~~~~~~~~~~~~~~~~~~~ +When files are served at a dedicated endpoint, clients can request partial +downloads. This allows them to provide features such as optimized +pause/resume (with no need to restart the download). To perform a partial +download, make sure the ``Range`` header is added the the client request. + + .. code-block:: console + + $ curl http://localhost/media/yourfile -i -H "Range: bytes=0-10" + HTTP/1.1 206 PARTIAL CONTENT + Date: Sun, 20 Aug 2017 14:26:42 GMT + Content-Type: audio/mp4 + Content-Length: 11 + Connection: keep-alive + Content-Range: bytes 0-10/23671 + Last-Modified: Sat, 19 Aug 2017 03:25:36 GMT + Accept-Ranges: bytes + + abcdefghilm + +In the snippet above, we see curl requesting the first chunk of a file. + .. _projection_filestorage: Leveraging Projections to optimize the handling of media files @@ -1630,14 +1909,14 @@ response payloads by sending requests like this one: .. _multipart: -Note on media files as ``multipart/data-form`` +Note on media files as ``multipart/form-data`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -If you are uploading media files as ``multipart/data-form`` all the +If you are uploading media files as ``multipart/form-data`` all the additional fields except the file fields will be treated as ``strings`` for all field validation purposes. If you have already defined some of the resource fields to be of different type (boolean, number, list etc) the validation rules for these fields would fail, preventing you to -succesffully submit your resource. +successffully submit your resource. If you still want to be able to perform field validation in this case, you will have to turn on ``MULTIPART_FORM_FIELDS_AS_JSON`` in your settings @@ -1656,6 +1935,15 @@ quotes). If ever in doubt if what you are submitting is a valid JSON string you can try passing it from the JSON Validator at http://jsonlint.com/ to be sure that it is correct. +.. _media_lists: + +Using lists of media +~~~~~~~~~~~~~~~~~~~~ +When using lists of media, there is no way to submit these in the default +configuration. Enable ``AUTO_COLLAPSE_MULTI_KEYS`` and ``AUTO_CREATE_LISTS`` +to make this possible. This allows to send multiple values for one key in +``multipart/form-data`` requests and in this way upload a list of files. + .. _geojson_feature: GeoJSON @@ -1671,11 +1959,11 @@ encoded in GeoJSON_ format. All GeoJSON objects supported by MongoDB_ are availa - ``MultiPolygon`` - ``GeometryCollection`` -These are implemented as native Eve data types (see :ref:`schema`) so they are -are subject to proper validation. +All these objects are implemented as native Eve data types (see :ref:`schema`) +so they are are subject to the proper validation. In the example below we are extending the `people` endpoint by adding -a ``location`` field is of type Point_. +a ``location`` field of type Point_. .. code-block:: javascript @@ -1694,6 +1982,13 @@ Storing a contact along with its location is pretty straightforward: $ curl -d '[{"firstname": "barack", "lastname": "obama", "location": {"type":"Point","coordinates":[100.0,10.0]}}]' -H 'Content-Type: application/json' http://127.0.0.1:5000/people HTTP/1.1 201 OK +Eve also supports GeoJSON ``Feature`` and ``FeatureCollection`` objects, which +are not explicitely mentioned in MongoDB_ documentation. GeoJSON specification +allows object to contain any number of members (name/value pairs). Eve +validation was implemented to be more strict, allowing only two members. This +restriction can be disabled by setting ``ALLOW_CUSTOM_FIELDS_IN_GEOJSON`` to +``True``. + Querying GeoJSON Data ~~~~~~~~~~~~~~~~~~~~~ As a general rule all MongoDB `geospatial query operators`_ and their associated @@ -1821,7 +2116,7 @@ time a custom function is invoked. 'url: %(url)s, method:%(method)s')) # the default log level is set to WARNING, so - # we have to explictly set the logging level + # we have to explicitly set the logging level # to INFO to get our custom message logged. app.logger.setLevel(logging.INFO) @@ -1846,7 +2141,7 @@ oplog is simply a server log. What makes it a little bit different is that it can be exposed as a read-only endpoint, thus allowing clients to query it as they would with any other API endpoint. -Every oplog entry contains informations about the document and the operation: +Every oplog entry contains information about the document and the operation: - Operation performed - Unique ID of the document @@ -1902,12 +2197,12 @@ more on this later). Please note that by default the ``c`` (changes) field is not included for ``POST`` operations. You can add ``POST`` to the ``OPLOG_CHANGE_METHODS`` -setting (see :ref:`global`) if you whish the whole document to be included on +setting (see :ref:`global`) if you wish the whole document to be included on every insertion. How is the oplog operated? ~~~~~~~~~~~~~~~~~~~~~~~~~~ -Six settings are dedicated to the OpLog: +Seven settings are dedicated to the OpLog: - ``OPLOG`` switches the oplog feature on and off. Defaults to ``False``. - ``OPLOG_NAME`` is the name of the oplog collection on the database. Defaults to ``oplog``. @@ -1920,7 +2215,7 @@ Six settings are dedicated to the OpLog: As you can see the oplog feature is turned off by default. Also, since ``OPLOG_ENDPOINT`` defaults to ``None``, even if you switch the feature on no -public oplog endpoint will be available. You will have to explictly set the +public oplog endpoint will be available. You will have to explicitly set the endpoint name in order to expose your oplog to the public. The Oplog endpoint @@ -1968,13 +2263,13 @@ each entry: app.on_oplog_push += oplog_extras app.run() -Please note that unless you explictly set ``OPLOG_RETURN_EXTRA_FIELD`` to +Please note that unless you explicitly set ``OPLOG_RETURN_EXTRA_FIELD`` to ``True``, the ``extra`` field will *not* be returned by the ``OPLOG_ENDPOINT``. .. note:: Are you on MongoDB? Consider making the oplog a `capped collection`_. Also, - in case you are wondering yes, the Eve oplog is blatantly inpsired by the + in case you are wondering yes, the Eve oplog is blatantly inspired by the awesome `Replica Set Oplog`_. .. _schema_endpoint: @@ -1985,7 +2280,7 @@ Resource schema can be exposed to API clients by enabling Eve's schema endpoint. To do so, set the ``SCHEMA_ENDPOINT`` configuration option to the API endpoint name from which you want to serve schema data. Once enabled, Eve will treat the endpoint as a read only resource containing JSON encoded Cerberus -schema definitons, indexed by resource name. Resource visibility and +schema definitions, indexed by resource name. Resource visibility and authorization settings are honored, so internal resources or resources for which a request does not have read authentication will not be accessible at the schema endpoint. By default, ``SCHEMA_ENDPOINT`` is set to ``None``. @@ -2037,7 +2332,7 @@ Let's update the pipeline a little bit: } As you can see the `count` field is now going to sum the value of ``$value``, -which will be set by the client upon perfoming the request: +which will be set by the client upon performing the request: :: @@ -2050,21 +2345,58 @@ field/value pairs. Like with all other keywords, you can change ``aggregate`` to a keyword of your liking, just set ``QUERY_AGGREGATION`` in your settings. You can also set all options natively supported by PyMongo. For more -informations on aggregation see :ref:`datasource`. +information on aggregation see :ref:`datasource`. + +You can pass ``{}`` to fields which you want to ignore. Considering the +following pipelines: + +:: + + posts = { + 'datasource': { + 'aggregation': { + 'pipeline': [ + {"$match": { "name": "$name", "time": "$time"}} + {"$unwind": "$tags"}, + {"$group": {"_id": "$tags", "count": {"$sum": 1}}}, + ] + } + } + } + +If performing the following request: + +:: + + $ curl -i http://example.com/posts?aggregate={"$name": {"$regex": "Apple"}, "$time": {}} + +The stage ``{"$match": { "name": "$name", "time": "$time"}}`` in the pipeline +will be executed as ``{"$match": { "name": {"$regex": "Apple"}}}``. And for +the following request: + +:: + + $ curl -i http://example.com/posts?aggregate={"$name": {}, "$time": {}} + +The stage ``{"$match": { "name": "$name", "time": "$time"}}`` in the pipeline +will be completely skipped. + +The request above will ignore ``"count": {"$sum": "$value"}}``. A Custom +callback functions can be attached to the ``before_aggregation`` and +``after_aggregation`` event hooks. For more information, see +:ref:`aggregation_hooks`. Limitations ~~~~~~~~~~~ -``HATEOAS`` is not available at aggregation endpoints. This should not -be surprising as documents returned by these endpoints are aggregation results -and do not reside on the database, so there is no static link available for them. - Client pagination (``?page=2``) is enabled by default. This is currently -achieved by injecting two additional stages (``$limit`` first, then ``$skip``) -to the very end of the aggregation pipeline. You can turn pagination off by setting -``pagination`` to ``False`` for the endpoint. Keep in mind that, when pagination -is disabled, all aggregation results are included with every response. -Disabling pagination might be appropriate (and actually advisable) only if the -expected response payload is not huge. +achieved by injecting a ``$facet`` stage contianing two sub-pipelines, +total_count (``$count``) and paginated_results (``$limit`` first, then +``$skip``) to the very end of the aggregation pipeline after the +``before_aggregation`` hook. You can turn pagination off by setting +``pagination`` to ``False`` for the endpoint. Keep in mind that, when +pagination is disabled, all aggregation results are included with every +response. Disabling pagination might be appropriate (and actually advisable) +only if the expected response payload is not huge. Client sorting (``?sort=field1``) is not supported at aggregation endpoints. You can of course add one or more ``$sort`` stages to the pipeline, as we did @@ -2085,6 +2417,7 @@ A single endpoint cannot serve both regular and aggregation results. However, since it is possible to setup multiple endpoints all serving from the same datasource (see :ref:`source`), similar functionality can be easily achieved. + MongoDB and SQL Support ------------------------ Support for single or multiple MongoDB database/servers comes out of the box. @@ -2100,20 +2433,20 @@ niceties, like a built-in development server and debugger_, integrated support for unittesting_ and an `extensive documentation`_. .. _HATEOAS: http://en.wikipedia.org/wiki/HATEOAS -.. _Cerberus: https://github.com/nicolaiarocci/cerberus +.. _Cerberus: https://github.com/pyeve/cerberus .. _REST: http://en.wikipedia.org/wiki/Representational_state_transfer .. _CRUD: http://en.wikipedia.org/wiki/Create,_read,_update_and_delete .. _`CORS`: http://en.wikipedia.org/wiki/Cross-origin_resource_sharing -.. _`PostgreSQL effort`: https://github.com/nicolaiarocci/eve/issues/17 +.. _`PostgreSQL effort`: https://github.com/pyeve/eve/issues/17 .. _Flask: http://flask.pocoo.org .. _debugger: http://flask.pocoo.org/docs/quickstart/#debug-mode .. _unittesting: http://flask.pocoo.org/docs/testing/ .. _`extensive documentation`: http://flask.pocoo.org/docs/ .. _`this`: https://speakerdeck.com/nicola/developing-restful-web-apis-with-python-flask-and-mongodb?slide=113 -.. _Events: https://github.com/nicolaiarocci/events +.. _Events: https://github.com/pyeve/events .. _`MongoDB Data Model Design`: http://docs.mongodb.org/manual/core/data-model-design .. _GridFS: http://docs.mongodb.org/manual/core/gridfs/ -.. _MediaStorage: https://github.com/nicolaiarocci/eve/blob/develop/eve/io/media.py +.. _MediaStorage: https://github.com/pyeve/eve/blob/develop/eve/io/media.py .. _`driver documentation`: http://api.mongodb.org/python/2.7rc0/api/gridfs/grid_file.html#gridfs.grid_file.GridOut .. _GeoJSON: http://geojson.org/ .. _Point: http://geojson.org/geojson-spec.html#point diff --git a/docs/foreword.rst b/docs/foreword.rst index d105fa90c..917da2e39 100644 --- a/docs/foreword.rst +++ b/docs/foreword.rst @@ -10,7 +10,7 @@ should not be using it. Philosophy ---------- You have data stored somewhere and you want to expose it to your users -through a RESTful Web API. Eve is the tool that allows you to do so. +through a RESTful Web API. Eve is the tool that allows you to do so. Eve provides a robust, feature rich, REST-centered API implementation, and you just need to configure your API settings and behavior, plug in your @@ -41,12 +41,6 @@ number of use cases. I could then release it as an open source project. Well it turned out to be slightly more complex than that but finally here it is, and of course it's called Eve. -It still has a long way to go before it becomes the fully featured open source, -out-of-the-box API solution I envision (see the Roadmap below), but -I feel that at this point the codebase is ready for a public preview. -This will hopefully allow for some constructive feedback and maybe, for some -contributors to join the ranks. - REST, Flask and MongoDB ----------------------- The slides from my EuroPython talk, *Developing RESTful Web APIs with Flask and @@ -54,7 +48,7 @@ MongoDB*, are `available online`_. You might want to check them out to understan why and how certain design decisions were made, especially with regards to REST implementation. -BSD License +BSD License ----------- A large number of open source projects you find today are GPL Licensed. While the GPL has its time and place, it should most certainly not be your go-to diff --git a/docs/funding.rst b/docs/funding.rst new file mode 100644 index 000000000..43ddd02b7 --- /dev/null +++ b/docs/funding.rst @@ -0,0 +1,58 @@ +Funding +======= +We believe that collaboratively funded software can offer outstanding returns +on investment, by encouraging users to collectively share the cost of +development. + +The Eve REST framework continues to be open-source and permissively licensed, +but we firmly believe it is in the commercial best-interest for users of the +project to invest in its ongoing development. + +Signing up as a Backer or Sponsor will: + +- Directly contribute to faster releases, more features, and higher quality software. +- Allow more time to be invested in documentation, issue triage, and community support. +- Safeguard the future development of the Eve REST framework. + +If you run a business and is using Eve in a revenue-generating product, it +would make business sense to sponsor Eve development: it ensures the project +that your product relies on stays healthy and actively maintained. It can also +help your exposure in the Eve community and makes it easier to attract Eve +developers. + +Of course, individual users are also welcome to make a recurring pledge if Eve +has helped you in your work or personal projects. Alternatively, consider +donating as a sign of appreciation - like buying me coffee once in a while :) + +Support Eve development +----------------------- +You can support Eve development by pledging on GitHub, Patreon, or PayPal. + +- `Become a Backer on GitHub `_ +- `Become a Backer on Patreon `_ +- `Donate via PayPal `_ (one time) + +Backers +~~~~~~~ +Backers who actively support Eve and Cerberus development: + +- Gabriel Wainer + +Eve Course at TalkPython Training +--------------------------------- +There is a 5 hours-long Eve course available for you at the fine TalkPython +Training website. The teacher is Nicola, Eve author and maintainer. Taking this +course will directly support the project. + +- `Take the Eve Course at TalkPython Training `_ + +Sponsored by +------------ +.. image:: _static/invoicetronic.svg + :target: https://invoicetronic.com/en/ + :width: 50 % + :alt: Invoicetronic is the leading API for electronic invoicing in Italy + +.. _`get in touch`: mailto:nicola@nicolaiarocci.com +.. _`Eve course`: https://training.talkpython.fm/courses/explore_eve/eve-building-restful-mongodb-backed-apis-course + diff --git a/docs/index.rst b/docs/index.rst index 36df230d7..ec1adfde2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,17 +1,39 @@ .. meta:: :description: Python REST API Framework to effortlessly build and deploy full featured, highly customizable RESTful Web Services. -Python REST API Framework -========================= +.. title:: Python REST API Framework: Eve, the Simple Way to REST. + +Eve. The Simple Way to REST +=========================== + +Version |release|. + +.. image:: https://img.shields.io/pypi/v/eve.svg?style=flat-square + :target: https://pypi.org/project/eve + +.. image:: https://github.com/pyeve/eve/workflows/CI/badge.svg + :target: https://github.com/pyeve/eve/actions?query=workflow%3ACI + +.. image:: https://img.shields.io/pypi/pyversions/eve.svg?style=flat-square + :target: https://pypi.org/project/eve + +.. image:: https://img.shields.io/badge/license-BSD-blue.svg?style=flat-square + :target: https://en.wikipedia.org/wiki/BSD_License + +.. image:: https://img.shields.io/badge/code%20style-black-000000.svg + :target: https://github.com/ambv/black + +----- + Eve is an :doc:`open source ` Python REST API framework designed for human beings. It allows to effortlessly build and deploy highly customizable, fully featured RESTful Web Services. -Eve is powered by Flask_, Cerberus_, Events_ and MongoDB_. Support for -SQL-Alchemy, Elasticsearch and Neo4js as alternate backends is provided by -community extensions_. +Eve is powered by Flask_ and Cerberus_ and it offers native support for +MongoDB_ data stores. Support for SQL, Elasticsearch and Neo4js backends is +provided by community extensions_. -The codebase is thoroughly tested under Python 2.6, 2.7, 3.3, 3.4, 3.5 and PyPy. +The codebase is thoroughly tested under Python 3.9+, and PyPy. Eve is Simple ------------- @@ -19,7 +41,9 @@ Eve is Simple from eve import Eve - app = Eve() + settings = {'DOMAIN': {'people': {}}} + + app = Eve(settings=settings) app.run() The API is now live, ready to be consumed: @@ -30,26 +54,31 @@ The API is now live, ready to be consumed: HTTP/1.1 200 OK All you need to bring your API online is a database, a configuration file -(defaults to ``settings.py``) and a launch script. Overall, you will find that -configuring and fine-tuning your API is a very simple process. - -.. _demo: - -Live demo ---------- -Check out the `live demo`_. If using a browser you will get XML back. For JSON -in the browser, you might want to install Postman_ or similar extension and -then set the ``Accept`` request header to ``application/json``. If you are -a CLI user (and you should), ``curl`` is your friend. The `source code`_ will -show you how easy it is to run an API with Eve. You will also find `usage -examples`_ for all common use cases (GET, POST, PATCH, DELETE and more). There -is also a simple `client app`_ available. - -Development Version --------------------- -If you are on python-eve.org_ then you are looking at the documentation of the -development version. Looking for last release docs? Follow `this -link `_. +(defaults to ``settings.py``) or dictionary, and a launch script. Overall, you +will find that configuring and fine-tuning your API is a very simple process. + +Funding Eve +----------- +Eve REST framework is a :doc:`collaboratively funded project `. If you +run a business and are using Eve in a revenue-generating product, it would make +business sense to sponsor Eve development: it ensures the project that your +product relies on stays healthy and actively maintained. Individual users are +also welcome to make either a recurring pledge or a one time donation if Eve +has helped you in your work or personal projects. Every single sign-up makes +a significant impact towards making Eve possible. + +You can support Eve development by pledging on GitHub, Patreon, or PayPal. + +- `Become a Backer on GitHub `_ +- `Become a Backer on Patreon `_ +- `Donate via PayPal `_ (one time) + +Sponsored by +------------ +.. image:: _static/invoicetronic.svg + :target: https://invoicetronic.com/en/ + :width: 50 % + :alt: Invoicetronic is the leading API for electronic invoicing in Italy .. toctree:: :hidden: @@ -62,11 +91,11 @@ link `_. config validation authentication + funding tutorials/index snippets/index extensions contributing - testing support updates authors @@ -75,21 +104,15 @@ link `_. .. note:: This documentation is under constant development. Please refer to the links - on the sidebar for more information. + on the sidebar for more information. .. _python-eve.org: http://python-eve.org -.. _`Eve Demo instructions`: http://github.com/nicolaiarocci/eve-demo#readme -.. _`live demo`: https://eve-demo.herokuapp.com/people -.. _`source code`: https://github.com/nicolaiarocci/eve-demo -.. _`usage examples`: https://github.com/nicolaiarocci/eve-demo#readme -.. _`client app`: https://github.com/nicolaiarocci/eve-demo-client -.. _Postman: https://www.google.com/url?sa=t&rct=j&q=&esrc=s&source=web&cd=1&cad=rja&ved=0CC0QFjAA&url=https%3A%2F%2Fchrome.google.com%2Fwebstore%2Fdetail%2Fpostman-rest-client%2Ffdmmgilgnpjigdojojpjoooidkmcomcm&ei=dPQ7UpqEBISXtAbPpIGwDg&usg=AFQjCNFL71vN61QG0LKlw7VDJvIZDprjHA&bvm=bv.52434380,d.Yms - +.. _Postman: https://www.getpostman.com .. _Flask: http://flask.pocoo.org/ .. _eve-sqlalchemy: https://github.com/RedTurtle/eve-sqlalchemy .. _MongoDB: https://mongodb.org .. _Redis: http://redis.io .. _Cerberus: http://python-cerberus.org -.. _events: https://github.com/nicolaiarocci/events -.. _extensions: http://python-eve.org/extensions +.. _events: https://github.com/pyeve/events +.. _extensions: http://python-eve.org/extensions.html diff --git a/docs/install.rst b/docs/install.rst index 5ff3d600b..9b5cdbe8b 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -5,7 +5,7 @@ Installation This part of the documentation covers the installation of Eve. The first step to using any software package is getting it properly installed. -Installing Eve is simple with `pip `_: +Installing Eve is simple with `pip `_: .. code-block:: console @@ -14,7 +14,7 @@ Installing Eve is simple with `pip `_: Development Version -------------------- Eve is actively developed on GitHub, where the code is `always available -`_. If you want to work with the +`_. If you want to work with the development version of Eve, there are two ways: you can either let `pip` pull in the development version, or you can tell it to operate on a git checkout. Either way, virtualenv is recommended. @@ -23,17 +23,20 @@ Get the git checkout in a new virtualenv and run in development mode. .. code-block:: console - $ git clone http://github.com/nicolaiarocci/eve.git - Initialized empty Git repository in ~/dev/eve/.git/ + $ git clone https://github.com/pyeve/eve.git + Cloning into 'eve'... + ... $ cd eve $ virtualenv venv - New python executable in venv/bin/python + ... + Installing setuptools, pip, wheel... + done. $ . venv/bin/activate - $ python setup.py install + $ pip install . ... - Finished processing dependencies for Eve + Successfully installed ... This will pull in the dependencies and activate the git head as the current version inside the virtualenv. Then all you have to do is run ``git pull @@ -47,10 +50,8 @@ To just get the development version without git, do this instead: $ cd eve $ virtualenv venv $ . venv/bin/activate - New python executable in venv/bin/python - - $ pip install git+git://github.com/nicolaiarocci/eve.git + $ pip install git+https://github.com/pyeve/eve.git ... - Cleaning up... + Successfully installed ... And you're done! diff --git a/docs/license.rst b/docs/license.rst index b25084ac6..5d3933909 100644 --- a/docs/license.rst +++ b/docs/license.rst @@ -15,4 +15,4 @@ Artwork License Eve artwork 2013 by Roberto Pasini "Kalamun" released under the `Creative Commons BY-SA`_ license. -.. _`Creative Commons BY-SA`: https://github.com/nicolaiarocci/eve/blob/develop/artwork/LICENSE +.. _`Creative Commons BY-SA`: https://github.com/pyeve/eve/blob/master/artwork/LICENSE diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 1c99c1f1d..49c8afe9c 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -3,12 +3,13 @@ Quickstart ========== -Eager to get started? This page gives a good introduction to Eve. It -assumes that: +Eager to get started? This page gives a first introduction to Eve. +Prerequisites +------------- - You already have Eve installed. If you do not, head over to the :ref:`install` section. -- MongoDB is installed_. +- MongoDB is installed_. - An instance of MongoDB is running_. A Minimal Application @@ -33,7 +34,7 @@ Save it as settings.py in the same directory where run.py is stored. This is the Eve configuration file, a standard Python module, and it is telling Eve that your API is comprised of just one accessible resource, ``people``. -Now your are ready to launch your API. +Now your are ready to launch your API. .. code-block:: console @@ -60,7 +61,7 @@ payload: "_links": { "child": [ { - "href": "people", + "href": "people", "title": "people" } ] @@ -80,24 +81,31 @@ Try requesting ``people`` now: :: { - "_items": [], + "_items": [], "_links": { "self": { - "href": "people", + "href": "people", "title": "people" - }, + }, "parent": { - "href": "/", + "href": "/", "title": "home" } + }, + "_meta": { + "max_results": 25, + "page": 1, + "total": 0 } } This time we also got an ``_items`` list. The ``_links`` are relative to the resource being accessed, so you get a link to the parent resource (the home -page) and to the resource itself. +page) and to the resource itself. If you got a timeout error from pymongo, make +sure the prerequisites are met. Chances are that the ``mongod`` server process +is not running. -By default Eve APIs are read-only: +By default Eve APIs are read-only: .. code-block:: console @@ -125,9 +133,12 @@ Let's connect to a database by adding the following lines to settings.py: MONGO_HOST = 'localhost' MONGO_PORT = 27017 - # Skip these if your db has no auth. But it really should. + # Skip this block if your db has no auth. But it really should. MONGO_USERNAME = '' MONGO_PASSWORD = '' + # Name of the database on which the user can be authenticated, + # needed if --auth mode is enabled. + MONGO_AUTH_SOURCE = '' MONGO_DBNAME = 'apitest' @@ -168,7 +179,7 @@ Let's define a schema for our ``people`` resource. schema = { # Schema definition, based on Cerberus grammar. Check the Cerberus project - # (https://github.com/nicolaiarocci/cerberus) for details. + # (https://github.com/pyeve/cerberus) for details. 'firstname': { 'type': 'string', 'minlength': 1, @@ -201,10 +212,10 @@ Let's define a schema for our ``people`` resource. }, } -For more information on validation see :ref:`validation`. +For more information on validation see :ref:`validation`. Now let's say that we want to further customize the ``people`` endpoint. We want -to: +to: - set the item title to ``person`` - add an extra :ref:`custom item endpoint ` at ``/people/`` @@ -223,7 +234,7 @@ file: # by default the standard item entry point is defined as # '/people/'. We leave it untouched, and we also enable an - # additional read-only entry point. This way consumers can also perform + # additional read-only entry point. This way consumers can also perform # GET requests at '/people/'. 'additional_lookup': { 'url': 'regex("[\w]+")', @@ -265,10 +276,10 @@ endpoint: $ curl -i http://127.0.0.1:5000/people/obama HTTP/1.0 200 OK Etag: 28995829ee85d69c4c18d597a0f68ae606a266cc - Last-Modified: Wed, 21 Nov 2012 16:04:56 GMT + Last-Modified: Wed, 21 Nov 2012 16:04:56 GMT Cache-Control: 'max-age=10,must-revalidate' Expires: 10 - ... + ... .. code-block:: javascript @@ -288,11 +299,5 @@ endpoint: Cache directives and item title match our new settings. See :doc:`features` for a complete list of features available and more usage examples. -.. note:: - All examples and code snippets are from the :ref:`demo`, which is a fully - functional API that you can use to experiment on your own, either on the - live instance or locally (you can use the sample client app to populate - and/or reset the database). - .. _`installed`: http://docs.mongodb.org/manual/installation/ .. _running: http://docs.mongodb.org/manual/tutorial/manage-mongodb-processes/ diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 000000000..77fc977c4 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,5 @@ +# requirements to build documentation +Sphinx>=7.0,<8.0 +alabaster>=0.7.13,<1.0 +doc8 +eve diff --git a/docs/rest_api_for_humans.rst b/docs/rest_api_for_humans.rst index 56c5e20be..f9b6cf595 100644 --- a/docs/rest_api_for_humans.rst +++ b/docs/rest_api_for_humans.rst @@ -6,19 +6,23 @@ rundown on Eve features, along with a few code snippets and examples. Hopefully it will do a good job in letting you decide whether Eve is valid solution for your use case. -.. embedly:: http://speakerdeck.com/nicola/eve-rest-api-for-humans +- `REST API for Humans @ SpeakerDeck `_ + Conferences ------------ Eve REST API for Humans™ has been presented at the following events so far: +- PyConWeb 2018, Munich +- PyCon Belarus 2018, Kiev +- Codemotion 2017, Rome - PiterPy 2016, St. Petersburg - Percona Live 2015, Amsterdam - EuroPython 2014, Berlin -- Python Meetup, Helsinki -- PyCon Italy 2014, Florence -- PyCon Sweden 2014, Stockholm -- FOSDEM 2014, Brussels +- Python Meetup, Helsinki +- PyCon Italy 2014, Florence +- PyCon Sweden 2014, Stockholm +- FOSDEM 2014, Brussels Want this talk delivered at your conference? Get in touch_! diff --git a/docs/snippets/hooks_blueprints.rst b/docs/snippets/hooks_blueprints.rst index bdc65418e..80366fccc 100644 --- a/docs/snippets/hooks_blueprints.rst +++ b/docs/snippets/hooks_blueprints.rst @@ -47,7 +47,7 @@ properly MongoDB collection. {"$set": {"user": None}}, multi=True ) - + app = Eve() # register the blueprint to the main Eve application app.register_blueprint(blueprint) diff --git a/docs/snippets/index.rst b/docs/snippets/index.rst index 222f2d161..0ccc7fcf1 100644 --- a/docs/snippets/index.rst +++ b/docs/snippets/index.rst @@ -4,16 +4,17 @@ Snippets ======== Welcome to the Eve snippet archive. This is the place where anyone can drop -helpful pieces of code for others to use. +helpful pieces of code for others to use. Available Snippets ------------------ .. toctree:: - :maxdepth: 2 + :maxdepth: 1 hooks_blueprints - + list_of_items + Add your snippet ---------------- @@ -26,7 +27,7 @@ source_), and then submit a `pull request`_. template -.. _template: https://raw.githubusercontent.com/nicolaiarocci/eve/develop/docs/snippets/template.rst -.. _`pull request`: https://github.com/nicolaiarocci/eve/pulls -.. _`snippets folder`: https://github.com/nicolaiarocci/eve/tree/develop/docs/snippets -.. _source: https://raw.githubusercontent.com/nicolaiarocci/eve/develop/docs/snippets/index.rst +.. _template: https://raw.githubusercontent.com/pyeve/eve/master/docs/snippets/template.rst +.. _`pull request`: https://github.com/pyeve/eve/pulls +.. _`snippets folder`: https://github.com/pyeve/eve/tree/master/docs/snippets +.. _source: https://raw.githubusercontent.com/pyeve/eve/master/docs/snippets/index.rst diff --git a/docs/snippets/list_of_items.rst b/docs/snippets/list_of_items.rst new file mode 100644 index 000000000..f77b277c4 --- /dev/null +++ b/docs/snippets/list_of_items.rst @@ -0,0 +1,153 @@ +Supporting both list-level and item-level CRUD operations +========================================================= +by John Chang + +This is an example of how to implement a simple list of items that supports both list-level and item-level CRUD operations. + +Specifically, it should be possible to use a single GET to get the entire list (including all items) but also a single POST to append an item (rather than PATCHing the list). + +The solution was to database event hooks to inject the embedded child documents (``items``) into the parent list before it's returned to the client and also delete the child items when the parent list is deleted. This works, although it results in two DB queries. + +main.py +------- +.. code-block:: python + + from eve import Eve + from bson.objectid import ObjectId + + app = Eve() + mongo = app.data.driver + + + def after_fetching_lists(response): + list_id = response['_id'] + f = {'list_id': ObjectId(list_id)} + response['items'] = list(mongo.db.items.find(f)) + + + def after_deleting_lists(item): + list_id = item['_id'] + f = {'list_id': ObjectId(list_id)} + mongo.db.items.delete_many(f) + + app.on_fetched_item_lists += after_fetching_lists + app.on_deleted_item_lists += after_deleting_lists + + app.run() + +settings.py +----------- +.. code-block:: python + + import os + + DEBUG = True + + MONGO_HOST = os.environ.get('MONGO_HOST', 'localhost') + MONGO_PORT = os.environ.get('MONGO_PORT', 27017) + MONGO_USERNAME = os.environ.get('MONGO_USERNAME', 'user') + MONGO_PASSWORD = os.environ.get('MONGO_PASSWORD', 'user') + MONGO_DBNAME = os.environ.get('MONGO_DBNAME', 'listtest') + + RESOURCE_METHODS = ['GET', 'POST', 'DELETE'] + ITEM_METHODS = ['GET', 'PUT', 'PATCH', 'DELETE'] + + DOMAIN = { + 'lists': { + 'schema': { + 'title': { + 'type': 'string' + } + } + }, + 'items': { + 'url': 'lists//items', + 'schema': { + 'list_id': { + 'type': 'objectid', + 'data_relation': { + 'resource': 'lists', + 'field': '_id' + } + }, + 'name': { + 'type': 'string', + 'required': True + } + } + } + } + +Usage +----- +.. code-block:: bash + + $ curl -i -X POST http://127.0.0.1:5000/lists -d title="My List" + HTTP/1.0 201 CREATED + + { + "_id": "58960f83a663e2e6746dfa6a", + : + } + + $ curl -i -X POST http://127.0.0.1:5000/lists/58960f83a663e2e6746dfa6a/items -d 'name=Alice' + HTTP/1.0 201 CREATED + + $ curl -i -X POST http://127.0.0.1:5000/lists/58960f83a663e2e6746dfa6a/items -d 'name=Bob' + HTTP/1.0 201 CREATED + + $ curl -i -X GET http://127.0.0.1:5000/lists/58960f83a663e2e6746dfa6a + HTTP/1.0 200 OK + + { + "_created": "Sat, 04 Feb 2017 17:29:39 GMT", + "_etag": "01799f6be25a044ab95cfeb2dc0f834d11b796d8", + "_id": "58960f83a663e2e6746dfa6a", + "_updated": "Sat, 04 Feb 2017 17:29:39 GMT", + "items": [ + { + "_created": "Sat, 04 Feb 2017 17:30:06 GMT", + "_etag": "72ad9248ad5bf45c7bfe3e03a1b9bc384d94572f", + "_id": "58960f9ea663e2e6746dfa6b", + "_updated": "Sat, 04 Feb 2017 17:30:06 GMT", + "list_id": "58960f83a663e2e6746dfa6a", + "name": "Alice", + "quantity": 1 + }, + { + "_created": "Sat, 04 Feb 2017 17:30:13 GMT", + "_etag": "447f51b057fb5e0a70472e96ff883c64b5e2e308", + "_id": "58960fa5a663e2e6746dfa6c", + "_updated": "Sat, 04 Feb 2017 17:30:13 GMT", + "list_id": "58960f83a663e2e6746dfa6a", + "name": "Bob", + "quantity": 1 + } + ], + "title": "My List" + } + + $ curl -i -X DELETE http://127.0.0.1:5000/lists/58960f83a663e2e6746dfa6a/items/58960f9ea663e2e6746dfa6b -H "If-Match: 72ad9248ad5bf45c7bfe3e03a1b9bc384d94572f" + HTTP/1.0 204 NO CONTENT + + $ curl -i -X GET http://127.0.0.1:5000/lists/58960f83a663e2e6746dfa6a + HTTP/1.0 200 OK + + { + "_created": "Sat, 04 Feb 2017 17:29:39 GMT", + "_etag": "01799f6be25a044ab95cfeb2dc0f834d11b796d8", + "_id": "58960f83a663e2e6746dfa6a", + "_updated": "Sat, 04 Feb 2017 17:29:39 GMT", + "items": [ + { + "_created": "Sat, 04 Feb 2017 17:30:13 GMT", + "_etag": "447f51b057fb5e0a70472e96ff883c64b5e2e308", + "_id": "58960fa5a663e2e6746dfa6c", + "_updated": "Sat, 04 Feb 2017 17:30:13 GMT", + "list_id": "58960f83a663e2e6746dfa6a", + "name": "Bob", + "quantity": 1 + } + ], + "title": "My List" + } diff --git a/docs/snippets/template.rst b/docs/snippets/template.rst index 16e268d1e..3d7338a71 100644 --- a/docs/snippets/template.rst +++ b/docs/snippets/template.rst @@ -14,4 +14,4 @@ experience. Make your code snippet follow, like so: app = Eve() app.run() -Add closing comments as needed. +Add closing comments as needed. diff --git a/docs/support.rst b/docs/support.rst index d35b36090..ec5644fd3 100644 --- a/docs/support.rst +++ b/docs/support.rst @@ -2,28 +2,31 @@ Support ======= -If you have any questions or issues about Eve, there are several options: +Please keep in mind that the issues on GitHub are reserved for bugs and +feature requests. If you have general or usage questions about Eve, there +are several options: + +Stack Overflow +-------------- +`Stack Overflow`_ has a eve tag. It is generally followed by Eve developers +and users. Mailing List ------------ The `mailing list`_ is intended to be a low traffic resource for both developers/contributors and API maintainers looking for help or requesting -feedback. - -File an Issue -------------- -If you notice some unexpected behavior in Eve, or want to see support for a new -feature, `file an issue on GitHub -`_. - -Send a Tweet ------------- -If your question is less than 140 characters, feel free to send a tweet to -`@nicolaiarocci `_. +feedback. IRC --- There is an official Freenode channel for Eve at `#python-eve `_. +File an Issue +------------- +If you notice some unexpected behavior in Eve, or want to see support for a new +feature, `file an issue on GitHub +`_. + .. _`mailing list`: https://groups.google.com/forum/#!forum/python-eve +.. _`Stack Overflow`: https://stackoverflow.com/questions/tagged/eve diff --git a/docs/testing.rst b/docs/testing.rst deleted file mode 100644 index f4dcee7c8..000000000 --- a/docs/testing.rst +++ /dev/null @@ -1,189 +0,0 @@ -Running the Tests -================= -Eve runs under Python 2.6, Python 2.7, Python 3.3 and PyPy. Therefore tests -will be run in those four platforms in our `continuous integration server`_. - -The easiest way to get started is to run the tests in your local environment -with: - -.. code-block:: console - - $ python setup.py test - -If you want you can run a single module, say the ``methods`` suite: - -.. code-block:: console - - $ python setup.py test -s eve.tests.methods - -Or, to run only the ``get`` tests: - -.. code-block:: console - - $ python setup.py test -s eve.tests.methods.get - -You can also choose to just run a single class: - -.. code-block:: console - - $ python setup.py test -s eve.tests.methods.get.TestGetItem - -Or even a single class function: - -.. code-block:: console - - $ python setup.py test -s eve.tests.methods.get.TestGetItem.test_expires - - -.. _test_prerequisites: - -Prerequisites -------------- - -Install the required dependencies for running tests and building documentation -by running :: - - $ pip install -r dev-requirements.txt - -Testing with other python versions ----------------------------------- -Before you submit a pull request, make sure your tests and changes run in -all supported python versions: 2.6, 2.7, 3.3, 3.4, 3.5 and PyPy. Instead of creating all -those environments by hand, Eve uses tox_. - -Make sure you have all required python versions installed and run: - -.. code-block:: console - - $ pip install tox # First time only - $ tox - -This might take some time the first run as the different virtual environments -are created and dependencies are installed. If everything is ok, you will see -the following: - -.. code-block:: console - - _________ summary _________ - py26: commands succeeded - py27: commands succeeded - py33: commands succeeded - py34: commands succeeded - py35: commands succeeded - pypy: commands succeeded - flake8: commands succeeded - congratulations :) - -If something goes **wrong** and one test fails, you might need to run that test -in the specific python version. You can use the created environments to run -some specific tests. For example, if a test suite fails in Python 3.4: - -.. code-block:: console - - # From the project folder - $ tox -e py34 -- -s eve.tests.methods.get.TestGetItem - -Using Pytest -------------- -You also choose to run the whole test suite using pytest_: - -.. code-block:: console - - # Run the whole test suite - $ py.test - - # Run all tests in the 'methods' folder - $ py.test eve/tests/methods - - # Run all the tests named 'TestEvents' - $ py.test -k TestEvents - - # Run the specific test class - $ py.test eve/tests/methods/get.py::TestEvents - - # Run the specific test - $ py.test eve/tests/auth.py::TestBasicAuth::test_custom_auth - - -You can use pytest_ from tox_, but you will need to install it in the tox -environments before using it. - -.. code-block:: console - - $ .tox/py26/bin/pip install pytest - $ .tox/py26/bin/py.test - -Please note that, just for my own convenience, the ``pytest.ini`` file is -currently set up in such a way that any test run will abort after two failures. -Also, if you are a Vim_ user (you should), you might want to check out the awesome -pytest.vim_ plugin. - - -RateLimiting and Redis ----------------------- -While there are no test requirements for most of the suite, please be advised -that in order to execute the :ref:`ratelimiting` tests you need a running -Redis_ server, and redispy_ must be installed. The Rate-Limiting tests are -silently skipped if any of the two conditions are not met. - -Redispy will install automatically on the first test run, or you can install it -yourself with - -.. code-block:: console - - $ pip install redis - -Continuous Integration ----------------------- -Each time code is pushed to either the ``develop`` or the ``master`` branch -the whole test-suite is executed on Travis-CI. This is also the case for -pull-requests. When a pull request is submitted and the CI run fails two things -happen: a 'the build is broken' email is sent to the submitter; the request is -rejected. The contributor can then fix the code, add one or more commits as -needed, and push again. - -The CI will also run flake8 so make sure that your code complies to PEP8 before -submitting a pull request, or be prepared to be mail-spammed by CI. - -Please note that in practice you're only supposed to submit pull requests -against the ``develop`` branch, see :ref:`contributing`. - -Building documentation ----------------------- -Eve uses Sphinx_ for its documentation. To build the documentation locally, -switch to the ``docs`` folder and run :: - - $ make html - -This will generate html documentation in the folder ``~/code/eve.docs/html``, -which can be overridden with the ``BUILDDIR`` make variable :: - - $ make html BUILDDIR=/path/to/docs - -Make sure Sphinx_ reports no errors or warnings when running the above. - -To preview the documentation open ``index.html`` in the build directory :: - - $ open /path/to/docs/index.html - -Alternatively switch to the build directory, start a local webserver :: - - $ python3 -m http.server - -and then point your browser at ``localhost:8000``. - -.. note:: - - Eve uses a customised Sphinx_ theme based on alabaster_. The easiest way - to get the right version is by installing the :ref:`test_prerequisites`. - -.. _`continuous integration server`: https://travis-ci.org/nicolaiarocci/eve/ -.. _tox: http://tox.readthedocs.org/en/latest/ -.. _Redis: http://redis.io/ -.. _redispy: https://github.com/andymccurdy/redis-py -.. _simple: http://redis.io/topics/quickstart -.. _pytest: http://pytest.org -.. _pytest.vim: https://github.com/alfredodeza/pytest.vim -.. _Vim: http://en.wikipedia.org/wiki/Vim_(text_editor) -.. _Sphinx: http://sphinx-doc.org -.. _alabaster: https://pypi.python.org/pypi/alabaster diff --git a/docs/tutorials/account_management.rst b/docs/tutorials/account_management.rst index e00e88307..fba0e6bea 100644 --- a/docs/tutorials/account_management.rst +++ b/docs/tutorials/account_management.rst @@ -5,7 +5,7 @@ RESTful Account Management This tutorial assumes that you've read the :ref:`quickstart` and the :ref:`auth` guides. -Except for the relatively rare occurence of open (and generally read-only) public +Except for the relatively rare occurrence of open (and generally read-only) public APIs, most services are only accessible to authenticated users. A common pattern is that users create their account on a website or with a mobile application. Once they have an account, they are allowed to consume one or more @@ -17,11 +17,11 @@ consumed by the accounts themselves? In the following paragraphs we'll see a couple of possible Account Management implementations, both making intensive use of a host of Eve features such as :ref:`endpointsec`, :ref:`roleaccess`, :ref:`user-restricted`, -:ref:`eventhooks`. +:ref:`eventhooks`. We assume that SSL/TLS is enabled, which means that our transport layer is encrypted, making both :ref:`basic` and :ref:`token` valid options to secure API -endpoints. +endpoints. Let's say we're upgrading the API we defined in the :ref:`quickstart` tutorial. @@ -32,7 +32,7 @@ Accounts with Basic Authentication Our tasks are as follows: 1. Make an endpoint available for all account management activities - (``/accounts``). + (``/accounts``). 2. Secure the endpoint, so that it is only accessible to clients that we control: our own website, mobile apps with account management capabilities, etc. @@ -66,8 +66,8 @@ Then, let's define the endpoint. accounts = { # the standard account entry point is defined as - # '/accounts/'. We define an additional read-only entry - # point accessible at '/accounts/'. + # '/accounts/'. We define an additional read-only entry + # point accessible at '/accounts/'. 'additional_lookup': { 'url': 'regex("[\w]+")', 'field': 'username', @@ -136,7 +136,7 @@ with simple POST requests, of course authenticating itself as a `superuser` by means of the `Authorization` header. The script assumes that stored passwords are encrypted with `bcrypt` (storing passwords as plain text is *never* a good idea). See :ref:`basic` for an alternative, faster but less secure SHA1/MAC -example. +example. 2b. User Roles Access Control ''''''''''''''''''''''''''''' @@ -170,7 +170,7 @@ Let's start by updating our resource schema. }, We just added a new ``roles`` field which is a required list. From now on, one -or more roles will have to be assigned on account creation. +or more roles will have to be assigned on account creation. Now we need to restrict endpoint access to `superuser` and `admin` accounts only so let's update the endpoint definition accordingly. @@ -180,8 +180,8 @@ only so let's update the endpoint definition accordingly. accounts = { # the standard account entry point is defined as - # '/accounts/'. We define an additional read-only entry - # point accessible at '/accounts/'. + # '/accounts/'. We define an additional read-only entry + # point accessible at '/accounts/'. 'additional_lookup': { 'url': 'regex("[\w]+")', 'field': 'username', @@ -194,7 +194,7 @@ only so let's update the endpoint definition accordingly. # Only allow superusers and admins. 'allowed_roles': ['superuser', 'admin'], - + # Finally, let's add the schema definition for this endpoint. 'schema': schema, } @@ -274,7 +274,7 @@ value: .. code-block:: python :emphasize-lines: 15-17 - + from eve import Eve from eve.auth import BasicAuth @@ -290,7 +290,7 @@ value: # only retrieve a user if his roles match ``allowed_roles`` lookup['roles'] = {'$in': allowed_roles} account = accounts.find_one(lookup) - # set 'AUTH_FIELD' value to the account's ObjectId + # set 'AUTH_FIELD' value to the account's ObjectId # (instead of _Id, you might want to use ID_FIELD) self.set_request_auth_value(account['_id']) return account and check_password_hash(account['password'], password) @@ -316,12 +316,12 @@ the token, and the password field is not provided (if included, it is ignored). Consequently, handling accounts with Token Authentication is very similar to what we saw in :ref:`accounts_basic`, but there's one little caveat: tokens need to be generated and stored along with the account, and eventually returned -to the client. +to the client. In light of this, let's review our updated task list: - + 1. Make an endpoint available for all account management activities - (``/accounts``). + (``/accounts``). 2. Secure the endpoint so that it is only accessible to clients (tokens) that we control. 3. On account creation, generate and store its token. @@ -362,7 +362,7 @@ need to add the `token` field to our schema: 2. Securing the ``/accounts/`` endpoint ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ We defined the `roles` field for the `accounts` schema in the previous step. -We also need to define the endpoint, making sure that we set the allowed +We also need to define the endpoint, making sure that we set the allowed user roles. .. code-block:: python @@ -370,8 +370,8 @@ user roles. accounts = { # the standard account entry point is defined as - # '/accounts/'. We define an additional read-only entry - # point accessible at '/accounts/'. + # '/accounts/'. We define an additional read-only entry + # point accessible at '/accounts/'. 'additional_lookup': { 'url': 'regex("[\w]+")', 'field': 'username', @@ -384,7 +384,7 @@ user roles. # Only allow superusers and admins. 'allowed_roles': ['superuser', 'admin'], - + # Finally, let's add the schema definition for this endpoint. 'schema': schema, } @@ -448,7 +448,7 @@ to be stored to the database. # Don't use this in production: # You should at least make sure that the token is unique. for document in documents: - document["token"] = (''.join(random.choice(string.ascii_uppercase) + document["token"] = (''.join(random.choice(string.ascii_uppercase) for x in range(10))) @@ -485,8 +485,8 @@ definition accordingly: accounts = { # the standard account entry point is defined as - # '/accounts/'. We define an additional read-only entry - # point accessible at '/accounts/'. + # '/accounts/'. We define an additional read-only entry + # point accessible at '/accounts/'. 'additional_lookup': { 'url': 'regex("[\w]+")', 'field': 'username', @@ -502,7 +502,7 @@ definition accordingly: # Allow 'token' to be returned with POST responses 'extra_response_fields': ['token'], - + # Finally, let's add the schema definition for this endpoint. 'schema': schema, } @@ -531,7 +531,7 @@ Despite being a little more tricky to set up on the server side, Token Authentication offers significant advantages. First, you don't have passwords stored on the client and being sent over the wire with every request. If you're sending your tokens out-of-band, and you're on SSL/TLS, that's quite -a lot of additional security. +a lot of additional security. .. _SSL/TLS: http://en.wikipedia.org/wiki/Transport_Layer_Security .. _`Event Hooks`: http://python-eve.org/features.html#event-hooks diff --git a/docs/tutorials/custom_idfields.rst b/docs/tutorials/custom_idfields.rst index cae787339..980210e8f 100644 --- a/docs/tutorials/custom_idfields.rst +++ b/docs/tutorials/custom_idfields.rst @@ -9,11 +9,11 @@ you configure a ``/invoices`` endpoint, which will allow clients to query the underlying `invoices` database collection. The ``/invoices/`` endpoint will be made available by the framework, and will be used by clients to retrieve and/or edit individual documents. By default, Eve provides this feature -seamlessly when ``ID_FIELD`` fields are of ``ObjectId`` type. +seamlessly when ``ID_FIELD`` fields are of ``ObjectId`` type. -However, you might have collections where your unique identifier is not and +However, you might have collections where your unique identifier is not an ``ObjectId``, and you still want individual document endpoints to work -properly. Don't worry, it's doable, it only requires a little tinkering. +properly. Don't worry, it's doable, it only requires a little tinkering. Handling ``UUID`` fields ------------------------ @@ -63,7 +63,7 @@ serialization magic: # will properly render ObjectIds, datetimes, etc.) return super(UUIDEncoder, self).default(obj) - + ``UUID`` Validation ~~~~~~~~~~~~~~~~~~~ By default Eve creates a unique identifier for each newly inserted document, @@ -82,18 +82,17 @@ details on custom validation): """ Extends the base mongo validator adding support for the uuid data-type """ - def _validate_type_uuid(self, field, value): + def _validate_type_uuid(self, value): try: UUID(value) + return True except ValueError: - self._error(field, "value '%s' cannot be converted to a UUID" % - value) - + pass ``UUID`` URLs ~~~~~~~~~~~~~ Now Eve is capable of rendering and validating UUID values but it still doesn't know -which resources are going to use these features. We also need to set +which resources are going to use these features. We also need to set ``item_url`` so uuid formed urls can be properly parsed. Let's pick our ``settings.py`` module and update the API domain accordingly: @@ -119,7 +118,7 @@ regex in order to avoid setting it for every single resource endpoint. Passing the ``UUID`` juice to Eve ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Now all the missing pieces are there we only need to instruct Eve on how to -use them. Eve needs to know about the new data type when its building the +use them. Eve needs to know about the new data type when its building the URL map, so we need to pass our custom classes right at the beginning, when we are instancing the application: @@ -137,6 +136,13 @@ supposed to pass the value, like so: POST {"name":"bill", "_id":"48c00ee9-4dbe-413f-9fc3-d5f12a91de1c"} +.. note:: + By default, Eve sets PyMongo's ``UuidRepresentation`` to ``standard``. + This allows for seamlessly handling of modern Python-generated UUID values. You + can change the default by setting the ``uuidRepresentation`` value of ``MONGO_OPTIONS`` + as desired. For more informations, see `PyMongo documentation`_. + .. _`custom url converters`: http://werkzeug.pocoo.org/docs/routing/#custom-converters .. _Flask: http://flask.pocoo.org/ .. _Werkzeug: http://werkzeug.pocoo.org/ +.. _PyMongo documentation: https://pymongo.readthedocs.io/en/stable/examples/uuid.html#configuring-uuid-representation diff --git a/docs/tutorials/index.rst b/docs/tutorials/index.rst index 75010b027..ee85e8e84 100644 --- a/docs/tutorials/index.rst +++ b/docs/tutorials/index.rst @@ -8,3 +8,11 @@ Tutorials account_management custom_idfields + +Learn Eve at TalkPython Training +-------------------------------- +There is a 5 hours-long Eve course available for you at the fine TalkPython +Training website. The teacher is Nicola, Eve author and maintainer. Taking this +course will directly support the project. + +- `Take the Eve Course at TalkPython Training `_ diff --git a/docs/updates.rst b/docs/updates.rst index 2a0312fc7..e7b3a2a69 100644 --- a/docs/updates.rst +++ b/docs/updates.rst @@ -7,7 +7,7 @@ there are several options: Blog ---- -`Eve News `_ is the official blog of the Eve project. +`Eve News `_ is the official blog of the Eve project. Twitter ------- @@ -18,11 +18,11 @@ Mailing List ------------ The `mailing list`_ is intended to be a low traffic resource for both developers/contributors and API maintainers looking for help or requesting -feedback. +feedback. GitHub ------ Of course the best way to track the development of Eve is through -`the GitHub repo `_. +`the GitHub repo `_. .. _`mailing list`: https://groups.google.com/forum/#!forum/python-eve diff --git a/docs/validation.rst b/docs/validation.rst index 63ab222fd..aeb074a55 100644 --- a/docs/validation.rst +++ b/docs/validation.rst @@ -9,7 +9,7 @@ will only be updated if validation passes. .. code-block:: console - $ curl -d '[{"firstname": "bill", "lastname": "clinton"}, {"firstname": "mitt", "lastname": "romney"}]' -H 'Content-Type: application/json' http://eve-demo.herokuapp.com/people + $ curl -d '[{"firstname": "bill", "lastname": "clinton"}, {"firstname": "mitt", "lastname": "romney"}]' -H 'Content-Type: application/json' http://myapi/people HTTP/1.1 201 OK The response will contain a success/error state for each item provided in the @@ -32,7 +32,7 @@ request: ] In the example above, the first document did not validate so the whole request -has been rejected. +has been rejected. When all documents pass validation and are inserted correctly the response status is ``201 Created``. If any document fails validation the response status @@ -40,14 +40,15 @@ is ``422 Unprocessable Entity``, or any other error code defined by ``VALIDATION_ERROR_STATUS`` configuration. For information on how to define documents schema and standard validation -rules, see :ref:`schema`. +rules, see :ref:`schema`. Extending Data Validation ------------------------- Data validation is based on the Cerberus_ validation system and it is therefore extensible. As a matter of fact, Eve's MongoDB data-layer itself extends Cerberus validation, implementing the ``unique`` and ``data_relation`` -constraints and the ``ObjectId`` data type on top of the standard rules. +constraints, the ``ObjectId`` data type and the ``decimal128`` on top of +the standard rules. .. _custom_validation_rules: @@ -81,7 +82,7 @@ can now do something like: 'schema': { 'oddity': { - 'isodd': True, + 'isodd': True, 'type': 'integer' } } @@ -97,16 +98,13 @@ code. .. code-block:: python - def _validate_type_objectid(self, field, value): + def _validate_type_objectid(self, value): """ Enables validation for `objectid` schema attribute. - :param unique: Boolean, whether the field value should be - unique or not. - :param field: field name. :param value: field value. """ - if not re.match('[a-f0-9]{24}', value): - self._error(field, ERROR_BAD_TYPE % 'ObjectId') + if isinstance(value, ObjectId): + return True This method enables support for MongoDB ``ObjectId`` type in your schema, allowing something like this: @@ -122,7 +120,7 @@ allowing something like this: You can also check the `source code`_ for Eve custom validation, where you will find more advanced use cases, such as the implementation of the ``unique`` and -``data_relation`` constraints. +``data_relation`` constraints. For more information on @@ -130,7 +128,11 @@ For more information on We have only scratched the surface of data validation. Please make sure to check the Cerberus_ documentation for a complete list of available - validation rules and data types. + validation rules and data types. + + Also note that Cerberus requirement is pinned to version 0.9.2, which still + supports the ``validate_update`` method used for ``PATCH`` requests. + Upgrade to Cerberus 1.0+ is scheduled for Eve version 0.8. .. _unknown: @@ -161,13 +163,13 @@ Consider the following domain: } } -You normally could only add (POST) or edit (PATCH) `firstnames` to the +Normally you can only add (POST) or edit (PATCH) `firstnames` to the ``/people`` endpoint. However, since ``allow_unknown`` has been enabled, even a payload like this will be accepted: .. code-block:: console - $ curl -d '[{"firstname": "bill", "lastname": "clinton"}, {"firstname": "bill", "age":70}]' -H 'Content-Type: application/json' http://eve-demo.herokuapp.com/people + $ curl -d '[{"firstname": "bill", "lastname": "clinton"}, {"firstname": "bill", "age":70}]' -H 'Content-Type: application/json' http://myapi/people HTTP/1.1 201 OK .. admonition:: Please note @@ -176,6 +178,12 @@ a payload like this will be accepted: option is enabled, clients will be capable of actually `adding` fields via PATCH (edit). +``ALLOW_UNKNOWN`` is also useful for read-only APIs or endpoints that +need to return the whole document, as found in the underlying database. In this +scenario you don't want to bother with validation schemas. For the whole API +just set ``ALLOW_UNKNOWN`` to ``True``, then ``schema: {}`` at every endpoint. +For a single endpoint, use ``allow_unknown: True`` instead. + .. _schema_validation: Schema validation @@ -184,16 +192,10 @@ Schema validation By default, schemas are validated to ensure they conform to the structure documented in :ref:`schema`. -There are two ways to deal with non-conforming schemas: - -1. Add :ref:`custom_validation_rules` for non-conforming keys used in the - schema. - -2. Set the global option ``TRANSPARENT_SCHEMA_RULES`` to disable schema - validation globally or the resource option ``transparent_schema_rules`` - to disable schema validation for a given endpoint. +In order to deal with non-conforming schemas, add +:ref:`custom_validation_rules` for non-conforming keys used in the schema. .. _Cerberus: http://python-cerberus.org -.. _`source code`: https://github.com/nicolaiarocci/eve/blob/develop/eve/io/mongo/validation.py +.. _`source code`: https://github.com/pyeve/eve/blob/master/eve/io/mongo/validation.py .. _`function-based validation`: http://docs.python-cerberus.org/en/latest/customize.html#function-validator .. _`type coercion`: http://docs.python-cerberus.org/en/latest/usage.html#type-coercion diff --git a/eve/__init__.py b/eve/__init__.py index ce3b19221..d963757fa 100644 --- a/eve/__init__.py +++ b/eve/__init__.py @@ -6,7 +6,7 @@ An out-of-the-box REST Web API that's as dangerous as you want it to be. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. .. versionchanged:: 0.5 @@ -38,47 +38,49 @@ """ -__version__ = '0.6.5.dev0' +__version__ = "2.3.1" # RFC 1123 (ex RFC 822) -DATE_FORMAT = '%a, %d %b %Y %H:%M:%S GMT' -RFC1123_DATE_FORMAT = '%a, %d %b %Y %H:%M:%S GMT' +DATE_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" +RFC1123_DATE_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" -URL_PREFIX = '' -API_VERSION = '' +URL_PREFIX = "" +API_VERSION = "" PAGINATION = True PAGINATION_LIMIT = 50 PAGINATION_DEFAULT = 25 -ID_FIELD = '_id' -CACHE_CONTROL = 'max-age=10,must-revalidate' # TODO confirm this value +ID_FIELD = "_id" +CACHE_CONTROL = "max-age=10,must-revalidate" # TODO confirm this value CACHE_EXPIRES = 10 -RESOURCE_METHODS = ['GET'] -ITEM_METHODS = ['GET'] +ALLOW_CUSTOM_FIELDS_IN_GEOJSON = False + +RESOURCE_METHODS = ["GET"] +ITEM_METHODS = ["GET"] ITEM_LOOKUP = True ITEM_LOOKUP_FIELD = ID_FIELD ITEM_URL = 'regex("[a-f0-9]{24}")' STATUS_OK = "OK" STATUS_ERR = "ERR" -LAST_UPDATED = '_updated' -DATE_CREATED = '_created' -ISSUES = '_issues' -STATUS = '_status' -ERROR = '_error' -ITEMS = '_items' -LINKS = '_links' -ETAG = '_etag' -VERSION = '_version' -META = '_meta' +LAST_UPDATED = "_updated" +DATE_CREATED = "_created" +ISSUES = "_issues" +STATUS = "_status" +ERROR = "_error" +ITEMS = "_items" +LINKS = "_links" +ETAG = "_etag" +VERSION = "_version" +META = "_meta" INFO = None -QUERY_WHERE = 'where' -QUERY_SORT = 'sort' -QUERY_PAGE = 'page' -QUERY_MAX_RESULTS = 'max_results' -QUERY_EMBEDDED = 'embedded' -QUERY_PROJECTION = 'projection' +QUERY_WHERE = "where" +QUERY_SORT = "sort" +QUERY_PAGE = "page" +QUERY_MAX_RESULTS = "max_results" +QUERY_EMBEDDED = "embedded" +QUERY_PROJECTION = "projection" VALIDATION_ERROR_STATUS = 422 VALIDATION_ERROR_AS_LIST = False diff --git a/eve/auth.py b/eve/auth.py index c15691fd6..c431f6e91 100644 --- a/eve/auth.py +++ b/eve/auth.py @@ -6,15 +6,18 @@ Allow API endpoints to be secured via BasicAuth and derivates. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ -from flask import request, Response, current_app as app, g, abort from functools import wraps +from flask import abort +from flask import current_app as app +from flask import g, request + def requires_auth(endpoint_class): - """ Enables Authorization logic for decorated functions. + """Enables Authorization logic for decorated functions. :param endpoint_class: the 'class' to which the decorated endpoint belongs to. Can be 'resource' (resource endpoint), 'item' @@ -30,59 +33,62 @@ def requires_auth(endpoint_class): .. versionadded:: 0.0.4 """ + def fdec(f): @wraps(f) def decorated(*args, **kwargs): - if endpoint_class == 'resource' or endpoint_class == 'item': - # find resource name in f's args + if endpoint_class == "resource" or endpoint_class == "item": if args: resource_name = args[0] - elif kwargs.get('resource'): - resource_name = kwargs.get('resource') + elif kwargs.get("resource"): + resource_name = kwargs.get("resource") else: - raise ValueError("'requires_auth(%s)' decorated functions " - "must include resource in args or kwargs" - % endpoint_class) + raise ValueError( + "'requires_auth(%s)' decorated functions " + "must include resource in args or kwargs" % endpoint_class + ) # fetch resource or item auth configuration - resource = app.config['DOMAIN'].get(resource_name) + resource = app.config["DOMAIN"].get(resource_name) if resource is None: abort(404) - if endpoint_class == 'resource': - public = resource['public_methods'] - roles = list(resource['allowed_roles']) - if request.method in ['GET', 'HEAD', 'OPTIONS']: - roles += resource['allowed_read_roles'] + if endpoint_class == "resource": + public = resource["public_methods"] + roles = list(resource["allowed_roles"]) + if request.method in ["GET", "HEAD", "OPTIONS"]: + roles += resource["allowed_read_roles"] else: - roles += resource['allowed_write_roles'] - elif endpoint_class == 'item': - public = resource['public_item_methods'] - roles = list(resource['allowed_item_roles']) - if request.method in ['GET', 'HEAD', 'OPTIONS']: - roles += resource['allowed_item_read_roles'] + roles += resource["allowed_write_roles"] + elif endpoint_class == "item": + public = resource["public_item_methods"] + roles = list(resource["allowed_item_roles"]) + if request.method in ["GET", "HEAD", "OPTIONS"]: + roles += resource["allowed_item_read_roles"] else: - roles += resource['allowed_item_write_roles'] + roles += resource["allowed_item_write_roles"] auth = resource_auth(resource_name) else: - # home + # home or media endpoints resource_name = resource = None - public = app.config['PUBLIC_METHODS'] + ['OPTIONS'] - roles = list(app.config['ALLOWED_ROLES']) - if request.method in ['GET', 'OPTIONS']: - roles += app.config['ALLOWED_READ_ROLES'] + public = app.config["PUBLIC_METHODS"] + ["OPTIONS"] + roles = list(app.config["ALLOWED_ROLES"]) + if request.method in ["GET", "OPTIONS"]: + roles += app.config["ALLOWED_READ_ROLES"] else: - roles += app.config['ALLOWED_WRITE_ROLES'] + roles += app.config["ALLOWED_WRITE_ROLES"] auth = app.auth if auth and request.method not in public: if not auth.authorized(roles, resource_name, request.method): return auth.authenticate() return f(*args, **kwargs) + return decorated + return fdec -class BasicAuth(object): - """ Implements Basic AUTH logic. Should be subclassed to implement custom +class BasicAuth(): + """Implements Basic AUTH logic. Should be subclassed to implement custom authentication checking. .. versionchanged:: 0.7 @@ -95,7 +101,7 @@ class BasicAuth(object): .. versionchanged:: 0.4 ensure all errors returns a parseable body #366. auth.request_auth_value replaced with getter and setter methods which - rely on flask's 'g' object, for enhanced thread-safity. + rely on flask's 'g' object, for enhanced thread-safety. .. versionchanged:: 0.1.1 auth.request_auth_value is now used to store the auth_field value. @@ -108,26 +114,27 @@ class BasicAuth(object): .. versionadded:: 0.0.4 """ + def set_mongo_prefix(self, value): g.mongo_prefix = value def get_mongo_prefix(self): - return g.get('mongo_prefix') + return g.get("mongo_prefix") def set_request_auth_value(self, value): g.auth_value = value def get_request_auth_value(self): - return g.get('auth_value') + return g.get("auth_value") def get_user_or_token(self): - return g.get('user') + return g.get("user") def set_user_or_token(self, user): g.user = user def check_auth(self, username, password, allowed_roles, resource, method): - """ This function is called to check if a username / password + """This function is called to check if a username / password combination is valid. Must be overridden with custom logic. :param username: username provided with current request. @@ -139,16 +146,17 @@ def check_auth(self, username, password, allowed_roles, resource, method): raise NotImplementedError def authenticate(self): - """ Returns a standard a 401 response that enables basic auth. + """Returns a standard a 401 response that enables basic auth. Override if you want to change the response and/or the realm. """ - resp = Response(None, 401, {'WWW-Authenticate': 'Basic realm="%s"' % - __package__}) - abort(401, description='Please provide proper credentials', - response=resp) + abort( + 401, + "Please provide proper credentials", + www_authenticate=("WWW-Authenticate", 'Basic realm="%s"' % __package__), + ) def authorized(self, allowed_roles, resource, method): - """ Validates the the current request is allowed to pass through. + """Validates the the current request is allowed to pass through. :param allowed_roles: allowed roles for the current request, can be a string or a list of roles. @@ -157,12 +165,13 @@ def authorized(self, allowed_roles, resource, method): auth = request.authorization if auth: self.set_user_or_token(auth.username) - return auth and self.check_auth(auth.username, auth.password, - allowed_roles, resource, method) + return auth and self.check_auth( + auth.username, auth.password, allowed_roles, resource, method + ) class HMACAuth(BasicAuth): - """ Hash Message Authentication Code (HMAC) authentication logic. Must be + """Hash Message Authentication Code (HMAC) authentication logic. Must be subclassed to implement custom authorization checking. .. versionchanged:: 0.7 @@ -180,9 +189,11 @@ class HMACAuth(BasicAuth): .. versionadded:: 0.0.5 """ - def check_auth(self, userid, hmac_hash, headers, data, allowed_roles, - resource, method): - """ This function is called to check if a token is valid. Must be + + def check_auth( + self, userid, hmac_hash, headers, data, allowed_roles, resource, method + ): + """This function is called to check if a token is valid. Must be overridden with custom logic. :param userid: user id included with the request. @@ -195,32 +206,32 @@ def check_auth(self, userid, hmac_hash, headers, data, allowed_roles, """ raise NotImplementedError - def authenticate(self): - """ Returns a standard a 401. Override if you want to change the - response. - """ - abort(401, description='Please provide proper credentials') - def authorized(self, allowed_roles, resource, method): - """ Validates the the current request is allowed to pass through. + """Validates the the current request is allowed to pass through. :param allowed_roles: allowed roles for the current request, can be a string or a list of roles. :param resource: resource being requested. """ - auth = request.headers.get('Authorization') + auth = request.headers.get("Authorization") try: - userid, hmac_hash = auth.split(':') + userid, hmac_hash = auth.split(":") self.set_user_or_token(userid) - except: + except Exception: auth = None - return auth and self.check_auth(userid, hmac_hash, request.headers, - request.get_data(), allowed_roles, - resource, method) + return auth and self.check_auth( + userid, + hmac_hash, + request.headers, + request.get_data(), + allowed_roles, + resource, + method, + ) class TokenAuth(BasicAuth): - """ Implements Token AUTH logic. Should be subclassed to implement custom + """Implements Token AUTH logic. Should be subclassed to implement custom authentication checking. .. versionchanged:: 0.7 @@ -235,8 +246,9 @@ class TokenAuth(BasicAuth): .. versionadded:: 0.0.5 """ + def check_auth(self, token, allowed_roles, resource, method): - """ This function is called to check if a token is valid. Must be + """This function is called to check if a token is valid. Must be overridden with custom logic. :param token: decoded user name. @@ -246,24 +258,15 @@ def check_auth(self, token, allowed_roles, resource, method): """ raise NotImplementedError - def authenticate(self): - """ Returns a standard a 401. Override if you want to change the - response. - """ - resp = Response(None, 401, {'WWW-Authenticate': 'Basic realm="%s"' % - __package__}) - abort(401, description='Please provide proper credentials', - response=resp) - def authorized(self, allowed_roles, resource, method): - """ Validates the the current request is allowed to pass through. + """Validates the the current request is allowed to pass through. :param allowed_roles: allowed roles for the current request, can be a string or a list of roles. :param resource: resource being requested. """ auth = None - if hasattr(request.authorization, 'username'): + if hasattr(request.authorization, "username"): auth = request.authorization.username # Werkzeug parse_authorization does not handle @@ -271,19 +274,18 @@ def authorized(self, allowed_roles, resource, method): # "Authorization: Token " or # "Authorization: Bearer " # headers, therefore they should be explicitly handled - if not auth and request.headers.get('Authorization'): - auth = request.headers.get('Authorization').strip() - if auth.lower().startswith(('token', 'bearer')): - auth = auth.split(' ')[1] + if not auth and request.headers.get("Authorization"): + auth = request.headers.get("Authorization").strip() + if auth.lower().startswith(("token", "bearer")): + auth = auth.split(" ")[1] if " " in auth else "" if auth: self.set_user_or_token(auth) - return auth and self.check_auth(auth, allowed_roles, resource, - method) + return auth and self.check_auth(auth, allowed_roles, resource, method) def auth_field_and_value(resource): - """ If auth is active and the resource requires it, return both the + """If auth is active and the resource requires it, return both the current request 'request_auth_value' and the 'auth_field' for the resource .. versionchanged:: 0.4 @@ -291,27 +293,30 @@ def auth_field_and_value(resource): .. versionadded:: 0.3 """ - if '|resource' in request.endpoint: + if request.endpoint and "|resource" in request.endpoint: # We are on a resource endpoint and need to check against # `public_methods` - public_method_list_to_check = 'public_methods' + public_method_list_to_check = "public_methods" else: # We are on an item endpoint and need to check against # `public_item_methods` - public_method_list_to_check = 'public_item_methods' + public_method_list_to_check = "public_item_methods" - resource_dict = app.config['DOMAIN'][resource] + resource_dict = app.config["DOMAIN"][resource] auth = resource_auth(resource) request_auth_value = auth.get_request_auth_value() if auth else None - auth_field = resource_dict.get('auth_field', None) if request.method not \ - in resource_dict[public_method_list_to_check] else None + auth_field = ( + resource_dict.get("auth_field", None) + if request.method not in resource_dict[public_method_list_to_check] + else None + ) return auth_field, request_auth_value def resource_auth(resource): - """ Ensure resource auth is an instance and its state is preserved between + """Ensure resource auth is an instance and its state is preserved between calls. .. versionchanged:: 0.6 @@ -319,7 +324,7 @@ def resource_auth(resource): .. versionadded:: 0.5.2 """ - resource_def = app.config['DOMAIN'][resource] - if callable(resource_def['authentication']): - resource_def['authentication'] = resource_def['authentication']() - return resource_def['authentication'] + resource_def = app.config["DOMAIN"][resource] + if callable(resource_def["authentication"]): + resource_def["authentication"] = resource_def["authentication"]() + return resource_def["authentication"] diff --git a/eve/default_settings.py b/eve/default_settings.py index 9e7791909..c5d37081a 100644 --- a/eve/default_settings.py +++ b/eve/default_settings.py @@ -8,12 +8,25 @@ appropriately, by using a custom settings module (see the optional 'settings' argument or the EVE_SETTING environment variable). - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. + .. versionchanged:: 2.0 + 'MONGO_OPTIONS', 'uuidRepresentation' option added. + + .. versionchanged:: 1.1.0 + 'MONGO_QUERY_WHITELIST' added and set to emtpy list. + + .. versionchanged:: 0.8 + 'RENDERERS' added with XML and JSON renderers. + 'JSON' removed. + 'XML' removed. + .. versionchanged:: 0.7 + 'OPTIMIZE_PAGINATION_FOR_SPEED' added and set to False. 'OPLOG_RETURN_EXTRA_FIELD' added and set to False. 'ENFORCE_IF_MATCH'added and set to True. + 'X_DOMAINS_RE' added and set to None .. versionchanged:: 0.6 'UPSERT_ON_PUT? added and set to True. @@ -96,23 +109,24 @@ # DEBUG = True # RFC 1123 (ex RFC 822) -DATE_FORMAT = '%a, %d %b %Y %H:%M:%S GMT' +DATE_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" STATUS_OK = "OK" STATUS_ERR = "ERR" -LAST_UPDATED = '_updated' -DATE_CREATED = '_created' -ISSUES = '_issues' -STATUS = '_status' -ERROR = '_error' -ITEMS = '_items' -LINKS = '_links' -ETAG = '_etag' -VERSION = '_version' # field that stores the version number -DELETED = '_deleted' # field to store soft delete status -META = '_meta' +LAST_UPDATED = "_updated" +DATE_CREATED = "_created" +ISSUES = "_issues" +STATUS = "_status" +ERROR = "_error" +ITEMS = "_items" +LINKS = "_links" +ETAG = "_etag" +VERSION = "_version" # field that stores the version number +DELETED = "_deleted" # field to store soft delete status +META = "_meta" INFO = None VALIDATION_ERROR_STATUS = 422 +NORMALIZE_DOTTED_FIELDS = True # return a single field validation error as a list (by default a single error # is retuned as string, while multiple errors are returned as a list). @@ -120,64 +134,70 @@ # codes for which we want to return a standard response which includes # a JSON body with the status, code, and description. -STANDARD_ERRORS = [400, 401, 404, 405, 406, 409, 410, 412, 422, 428] +STANDARD_ERRORS = [400, 401, 403, 404, 405, 406, 409, 410, 412, 422, 428, 429] # field returned on GET requests so we know if we have the latest copy even if # we access a specific version -LATEST_VERSION = '_latest_version' +LATEST_VERSION = "_latest_version" # appended to ID_FIELD, holds the original document id in parallel collection -VERSION_ID_SUFFIX = '_document' -VERSION_DIFF_INCLUDE = [] # always include these fields when diffing +VERSION_ID_SUFFIX = "_document" +VERSION_DIFF_INCLUDE = [] # always include these fields when diffing -API_VERSION = '' -URL_PREFIX = '' -ID_FIELD = '_id' -CACHE_CONTROL = '' +API_VERSION = "" +URL_PREFIX = "" +ID_FIELD = "_id" +CACHE_CONTROL = "" CACHE_EXPIRES = 0 -ITEM_CACHE_CONTROL = '' -X_DOMAINS = None # CORS disabled by default. -X_HEADERS = None # CORS disabled by default. -X_EXPOSE_HEADERS = None # CORS disabled by default. -X_ALLOW_CREDENTIALS = None # CORS disabled by default. -X_MAX_AGE = 21600 # Access-Control-Max-Age when CORS is enabled -HATEOAS = True # HATEOAS enabled by default. -IF_MATCH = True # IF_MATCH (ETag match) enabled by default. -ENFORCE_IF_MATCH = True # ENFORCE_IF_MATCH enabled by default. - -ALLOWED_FILTERS = ['*'] # filtering enabled by default +ITEM_CACHE_CONTROL = "" +X_DOMAINS = None # CORS disabled by default. +X_DOMAINS_RE = None # CORS disabled by default. +X_HEADERS = None # CORS disabled by default. +X_EXPOSE_HEADERS = None # CORS disabled by default. +X_ALLOW_CREDENTIALS = None # CORS disabled by default. +X_MAX_AGE = 21600 # Access-Control-Max-Age when CORS is enabled +HATEOAS = True # HATEOAS enabled by default. +IF_MATCH = True # IF_MATCH (ETag match) enabled by default. +ENFORCE_IF_MATCH = True # ENFORCE_IF_MATCH enabled by default. + +ALLOWED_FILTERS = ["*"] # filtering enabled by default VALIDATE_FILTERS = False -SORTING = True # sorting enabled by default. -JSON_SORT_KEYS = False # json key sorting -EMBEDDING = True # embedding enabled by default -PROJECTION = True # projection enabled by default -PAGINATION = True # pagination enabled by default. +SORTING = True # sorting enabled by default. +JSON_SORT_KEYS = False # json key sorting +RENDERERS = ["eve.render.JSONRenderer", "eve.render.XMLRenderer"] +EMBEDDING = True # embedding enabled by default +PROJECTION = True # projection enabled by default +PAGINATION = True # pagination enabled by default. PAGINATION_LIMIT = 50 PAGINATION_DEFAULT = 25 -VERSIONING = False # turn document versioning on or off. -VERSIONS = '_versions' # suffix for parallel collection w/old versions -VERSION_PARAM = 'version' # URL param for specific version of a document. -INTERNAL_RESOURCE = False # resources are public by default. -JSONP_ARGUMENT = None # JSONP disabled by default. -SOFT_DELETE = False # soft delete disabled by default. -SHOW_DELETED_PARAM = 'show_deleted' +VERSIONING = False # turn document versioning on or off. +VERSIONS = "_versions" # suffix for parallel collection w/old versions +VERSION_PARAM = "version" # URL param for specific version of a document. +INTERNAL_RESOURCE = False # resources are public by default. +JSONP_ARGUMENT = None # JSONP disabled by default. +SOFT_DELETE = False # soft delete disabled by default. +SHOW_DELETED_PARAM = "show_deleted" BULK_ENABLED = True -OPLOG = False # oplog is disabled by default. -OPLOG_NAME = 'oplog' # default oplog resource name. -OPLOG_ENDPOINT = None # oplog endpoint is disabled by default. -OPLOG_AUDIT = True # oplog audit enabled by default. -OPLOG_METHODS = ['DELETE', - 'POST', - 'PATCH', - 'PUT'] # oplog logs all operations by default. -OPLOG_CHANGE_METHODS = ['DELETE', - 'PATCH', - 'PUT'] # methods which write changes to the oplog -OPLOG_RETURN_EXTRA_FIELD = False # oplog does not return the 'extra' field. - -RESOURCE_METHODS = ['GET'] -ITEM_METHODS = ['GET'] +OPLOG = False # oplog is disabled by default. +OPLOG_NAME = "oplog" # default oplog resource name. +OPLOG_ENDPOINT = None # oplog endpoint is disabled by default. +OPLOG_AUDIT = True # oplog audit enabled by default. +OPLOG_METHODS = [ + "DELETE", + "POST", + "PATCH", + "PUT", +] # oplog logs all operations by default. +OPLOG_CHANGE_METHODS = [ + "DELETE", + "PATCH", + "PUT", +] # methods which write changes to the oplog +OPLOG_RETURN_EXTRA_FIELD = False # oplog does not return the 'extra' field. + +RESOURCE_METHODS = ["GET"] +ITEM_METHODS = ["GET"] PUBLIC_METHODS = [] ALLOWED_ROLES = [] ALLOWED_READ_ROLES = [] @@ -191,17 +211,21 @@ ITEM_LOOKUP = True ITEM_LOOKUP_FIELD = ID_FIELD ITEM_URL = 'regex("[a-f0-9]{24}")' -UPSERT_ON_PUT = True # insert unexisting documents on PUT. +UPSERT_ON_PUT = True # insert unexisting documents on PUT. +MERGE_NESTED_DOCUMENTS = True # use a simple file response format by default EXTENDED_MEDIA_INFO = [] RETURN_MEDIA_AS_BASE64_STRING = True RETURN_MEDIA_AS_URL = False -MEDIA_ENDPOINT = 'media' +MEDIA_ENDPOINT = "media" MEDIA_URL = 'regex("[a-f0-9]{24}")' MEDIA_BASE_URL = None MULTIPART_FORM_FIELDS_AS_JSON = False +AUTO_COLLAPSE_MULTI_KEYS = False +AUTO_CREATE_LISTS = False +JSON_REQUEST_CONTENT_TYPES = ["application/json"] SCHEMA_ENDPOINT = None @@ -212,15 +236,16 @@ BANDWIDTH_SAVER = True # default query parameters -QUERY_WHERE = 'where' -QUERY_PROJECTION = 'projection' -QUERY_SORT = 'sort' -QUERY_PAGE = 'page' -QUERY_MAX_RESULTS = 'max_results' -QUERY_EMBEDDED = 'embedded' -QUERY_AGGREGATION = 'aggregate' +QUERY_WHERE = "where" +QUERY_PROJECTION = "projection" +QUERY_SORT = "sort" +QUERY_PAGE = "page" +QUERY_MAX_RESULTS = "max_results" +QUERY_EMBEDDED = "embedded" +QUERY_AGGREGATION = "aggregate" -HEADER_TOTAL_COUNT = 'X-Total-Count' +HEADER_TOTAL_COUNT = "X-Total-Count" +OPTIMIZE_PAGINATION_FOR_SPEED = False # user-restricted resource access is disabled by default. AUTH_FIELD = None @@ -228,8 +253,9 @@ # don't allow unknown key/value pairs for POST/PATCH payloads. ALLOW_UNKNOWN = False -# don't ignore unknown schema rules (raise SchemaError) -TRANSPARENT_SCHEMA_RULES = False +# GeoJSON specs allows any number of key/value pairs +# http://geojson.org/geojson-spec.html#geojson-objects +ALLOW_CUSTOM_FIELDS_IN_GEOJSON = False # Rate limits are disabled by default. Needs a running redis-server. RATE_LIMIT_GET = None @@ -237,18 +263,17 @@ RATE_LIMIT_PATCH = None RATE_LIMIT_DELETE = None -# MONGO defaults -MONGO_HOST = 'localhost' -MONGO_PORT = 27017 # disallow Mongo's javascript queries as they might be vulnerable to injection # attacks ('ReDoS' especially), are probably too complex for the average API # end-user and finally can seriously impact overall performance. -MONGO_QUERY_BLACKLIST = ['$where', '$regex'] +MONGO_QUERY_BLACKLIST = ["$where", "$regex"] +MONGO_QUERY_WHITELIST = [] # Explicitly set default write_concern to 'safe' (do regular # aknowledged writes). This is also the current PyMongo/Mongo default setting. -MONGO_WRITE_CONCERN = {'w': 1} -MONGO_OPTIONS = { - 'connect': True -} -# Compatibility for flask-pymongo. -MONGO_CONNECT = MONGO_OPTIONS['connect'] +MONGO_WRITE_CONCERN = {"w": 1} +MONGO_OPTIONS = {"connect": True, "tz_aware": True, "uuidRepresentation": "standard"} + +# if true, the document will be normalized according to the schema during patch +# this means fields will be reset their the default value, if any, unless +# contained in the patch body. +NORMALIZE_ON_PATCH = True diff --git a/eve/defaults.py b/eve/defaults.py deleted file mode 100644 index 1d59bec3a..000000000 --- a/eve/defaults.py +++ /dev/null @@ -1,119 +0,0 @@ -# -*- coding: utf-8 -*- - -""" - Default values in schemas - ~~~~~~~~~~~~~~~~~~~~~~~~~ - - Default values for schemas work in two steps. - 1. The schema is searched for defaults and a list of default is built. - 2. In each POST/PUT request, for each default (if any) the document is - checked for a missing value, and if a value is missing the default is - added. - - :copyright: (c) 2016 by Nicola Iarocci. - :license: BSD, see LICENSE for more details. -""" - - -def build_defaults(schema): - """Build a tree of default values - - It walks the tree down looking for entries with a `default` key. In order - to avoid empty dicts the tree will be walked up and the empty dicts will be - removed. - - :param schema: Resource schema - :type schema: dict - :rtype: dict with defaults - - .. versionadded:: 0.4 - """ - # Pending schema nodes to process: loop and add defaults - pending = set() - # Stack of nodes to work on and clean up - stack = [(schema, None, None, {})] - level_schema, level_name, level_parent, current = stack[-1] - while len(stack) > 0: - leave = True - if isinstance(current, list): - level_schema = {'schema': level_schema.copy()} - for name, value in level_schema.items(): - default_next_level = None - if 'default' in value: - try: - current[name] = value['default'] - except TypeError: - current.append(value['default']) - elif value.get('type') == 'dict' and 'schema' in value: - default_next_level = {} - elif value.get('type') == 'list' and 'schema' in value: - default_next_level = [] - - if default_next_level is not None: - leave = False - next_level = add_next_level(name, current, default_next_level) - stack.append((value['schema'], name, current, next_level)) - pending.add(id(next_level)) - pending.discard(id(current)) - if leave: - # Leaves trigger the `walk up` till the next not processed node - while id(current) not in pending: - if not current and level_parent is not None: - try: - del level_parent[level_name] - except TypeError: - level_parent.remove(current) - stack.pop() - if len(stack) == 0: - break - level_schema, level_name, level_parent, current = stack[-1] - else: - level_schema, level_name, level_parent, current = stack[-1] - - return current - - -def add_next_level(name, current, default): - if isinstance(current, list): - current.append(default) - else: - default = current.setdefault(name, default) - return default - - -def resolve_default_values(document, defaults): - """ Add any defined default value for missing document fields. - - :param document: the document being posted or replaced - :param defaults: tree with the default values - :type defaults: dict - - .. versionchanged:: 0.5 - Fix #417. A default value of [] for a list causes an IndexError. - - .. versionadded:: 0.2 - """ - todo = [(defaults, document)] - while len(todo) > 0: - defaults, document = todo.pop() - if isinstance(defaults, list) and len(defaults): - todo.extend((defaults[0], item) for item in document) - continue - for name, value in defaults.items(): - if isinstance(value, dict): - # default dicts overwrite simple values - existing = document.setdefault(name, {}) - if not isinstance(existing, dict): - document[name] = {} - todo.append((value, document[name])) - if isinstance(value, list) and len(value): - existing = document.get(name) - if not existing: - document.setdefault(name, value) - continue - if all(isinstance(item, (dict, list)) for item in existing): - todo.extend((value[0], item) for item in existing) - else: - document.setdefault(name, existing) - else: - document.setdefault(name, value) diff --git a/eve/endpoints.py b/eve/endpoints.py index 580671f0b..b7c511d44 100644 --- a/eve/endpoints.py +++ b/eve/endpoints.py @@ -8,22 +8,26 @@ home) invokes the appropriate method handler, returning its response to the client, properly rendered. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ +import re + from bson import tz_util -from flask import abort, request, current_app as app, Response +from flask import Response, abort +from flask import current_app as app +from flask import request +import eve from eve.auth import requires_auth, resource_auth -from eve.methods import get, getitem, post, patch, delete, deleteitem, put +from eve.methods import delete, deleteitem, get, getitem, patch, post, put from eve.methods.common import ratelimit from eve.render import send_response -from eve.utils import config, weak_date, date_to_rfc1123 -import eve +from eve.utils import config, date_to_rfc1123, weak_date def collections_endpoint(**lookup): - """ Resource endpoint handler + """Resource endpoint handler :param url: the url that led here @@ -50,13 +54,13 @@ def collections_endpoint(**lookup): resource = _resource() response = None method = request.method - if method in ('GET', 'HEAD'): + if method in ("GET", "HEAD"): response = get(resource, lookup) - elif method == 'POST': + elif method == "POST": response = post(resource) - elif method == 'DELETE': + elif method == "DELETE": response = delete(resource, lookup) - elif method == 'OPTIONS': + elif method == "OPTIONS": send_response(resource, response) else: abort(405) @@ -64,7 +68,7 @@ def collections_endpoint(**lookup): def item_endpoint(**lookup): - """ Item endpoint handler + """Item endpoint handler :param url: the url that led here :param lookup: sub resource query @@ -88,15 +92,15 @@ def item_endpoint(**lookup): resource = _resource() response = None method = request.method - if method in ('GET', 'HEAD'): + if method in ("GET", "HEAD"): response = getitem(resource, **lookup) - elif method == 'PATCH': + elif method == "PATCH": response = patch(resource, **lookup) - elif method == 'PUT': + elif method == "PUT": response = put(resource, **lookup) - elif method == 'DELETE': + elif method == "DELETE": response = deleteitem(resource, **lookup) - elif method == 'OPTIONS': + elif method == "OPTIONS": send_response(resource, response) else: abort(405) @@ -104,9 +108,9 @@ def item_endpoint(**lookup): @ratelimit() -@requires_auth('home') +@requires_auth("home") def home_endpoint(): - """ Home/API entry point. Will provide links to each available resource + """Home/API entry point. Will provide links to each available resource .. versionchanged:: 0.5 Resource URLs are relative to API root. @@ -124,115 +128,166 @@ def home_endpoint(): response = {} if config.INFO: info = {} - info['server'] = 'Eve' - info['version'] = eve.__version__ + info["server"] = "Eve" + info["version"] = eve.__version__ if config.API_VERSION: - info['api_version'] = config.API_VERSION + info["api_version"] = config.API_VERSION response[config.INFO] = info if config.HATEOAS: links = [] for resource in config.DOMAIN.keys(): - internal = config.DOMAIN[resource]['internal_resource'] + internal = config.DOMAIN[resource]["internal_resource"] if not resource.endswith(config.VERSIONS): if not bool(internal): - links.append({'href': '%s' % config.URLS[resource], - 'title': '%s' % - config.DOMAIN[resource]['resource_title']}) + links.append( + { + "href": "%s" % config.URLS[resource], + "title": "%s" % config.DOMAIN[resource]["resource_title"], + } + ) if config.SCHEMA_ENDPOINT is not None: - links.append({'href': '%s' % config.SCHEMA_ENDPOINT, - 'title': '%s' % config.SCHEMA_ENDPOINT}) - - response[config.LINKS] = {'child': links} - return send_response(None, (response,)) - else: + links.append( + { + "href": "%s" % config.SCHEMA_ENDPOINT, + "title": "%s" % config.SCHEMA_ENDPOINT, + } + ) + + response[config.LINKS] = {"child": links} return send_response(None, (response,)) + return send_response(None, (response,)) def error_endpoint(error): - """ Response returned when an error is raised by the API (e.g. my means of + """Response returned when an error is raised by the API (e.g. my means of an abort(4xx). - - .. versionadded:: 0.4 """ - headers = None - if error.response: - headers = error.response.headers + headers = [] + + try: + headers.append(error.response.headers) + except AttributeError: + pass + + try: + if error.www_authenticate is not None: + headers.append(error.www_authenticate) + except AttributeError: + pass + response = { config.STATUS: config.STATUS_ERR, - config.ERROR: {'code': error.code, 'message': error.description}} + config.ERROR: {"code": error.code, "message": error.description}, + } return send_response(None, (response, None, None, error.code, headers)) def _resource(): - return request.endpoint.split('|')[0] + return request.endpoint.split("|")[0] +@requires_auth("media") def media_endpoint(_id): - """ This endpoint is active when RETURN_MEDIA_AS_URL is True. It retrieves + """This endpoint is active when RETURN_MEDIA_AS_URL is True. It retrieves a media file and streams it to the client. .. versionadded:: 0.6 """ + if request.method == "OPTIONS": + return send_response(None, (None)) + file_ = app.media.get(_id) if file_ is None: return abort(404) - if_modified_since = weak_date(request.headers.get('If-Modified-Since')) - if if_modified_since is not None: - if if_modified_since.tzinfo is None: - if_modified_since = if_modified_since.replace( - tzinfo=tz_util.utc) - - if if_modified_since > file_.upload_date: - return Response(status=304) - headers = { - 'Last-Modified': date_to_rfc1123(file_.upload_date), - 'Content-Length': file_.length, + "Last-Modified": date_to_rfc1123(file_.upload_date), + "Content-Length": file_.length, + "Accept-Ranges": "bytes", } - response = Response(file_, headers=headers, mimetype=file_.content_type, - direct_passthrough=True) + range_header = request.headers.get("Range") + if range_header: + status = 206 + + size = file_.length + try: + m = re.search(r"(\d+)-(\d*)", range_header) + begin, end = m.groups() + begin = int(begin) + end = int(end) + except Exception: + begin, end = 0, None + + length = size - begin + if end is not None: + length = end - begin + 1 + + file_.seek(begin) + + data = file_.read(length) + headers["Content-Range"] = "bytes {0}-{1}/{2}".format( + begin, begin + length - 1, size + ) + else: + if_modified_since = weak_date(request.headers.get("If-Modified-Since")) + if if_modified_since: + if not if_modified_since.tzinfo: + if_modified_since = if_modified_since.replace(tzinfo=tz_util.utc) + + if if_modified_since > file_.upload_date: + return Response(status=304) + + data = file_ + status = 200 + + response = Response( + data, + status=status, + headers=headers, + mimetype=file_.content_type, + direct_passthrough=True, + ) - return response + return send_response(None, (response,)) -@requires_auth('resource') +@requires_auth("resource") def schema_item_endpoint(resource): - """ This endpoint is active when SCHEMA_ENDPOINT != None. It returns the + """This endpoint is active when SCHEMA_ENDPOINT != None. It returns the requested resource's schema definition in JSON format. """ - resource_config = app.config['DOMAIN'].get(resource) - if not resource_config or resource_config.get('internal_resource') is True: + resource_config = app.config["DOMAIN"].get(resource) + if not resource_config or resource_config.get("internal_resource") is True: return abort(404) - return send_response(None, (resource_config['schema'],)) + return send_response(None, (resource_config["schema"],)) -@requires_auth('home') +@requires_auth("home") def schema_collection_endpoint(): - """ This endpoint is active when SCHEMA_ENDPOINT != None. It returns the + """This endpoint is active when SCHEMA_ENDPOINT != None. It returns the schema definition for all public or request authenticated resources in JSON format. """ schemas = {} - for resource_name, resource_config in app.config['DOMAIN'].items(): + for resource_name, resource_config in app.config["DOMAIN"].items(): # skip versioned shadow collections if resource_name.endswith(config.VERSIONS): continue # skip internal resources - internal = resource_config.get('internal_resource', False) + internal = resource_config.get("internal_resource", False) if internal: continue # skip resources for which request does not have read authorization auth = resource_auth(resource_name) - if auth and request.method not in resource_config['public_methods']: - roles = list(resource_config['allowed_roles']) - roles += resource_config['allowed_read_roles'] + if auth and request.method not in resource_config["public_methods"]: + roles = list(resource_config["allowed_roles"]) + roles += resource_config["allowed_read_roles"] if not auth.authorized(roles, resource_name, request.method): continue # otherwise include this resource in domain wide schema response - schemas[resource_name] = resource_config['schema'] + schemas[resource_name] = resource_config["schema"] return send_response(None, (schemas,)) diff --git a/eve/exceptions.py b/eve/exceptions.py index b5c8c3b78..52715e5df 100644 --- a/eve/exceptions.py +++ b/eve/exceptions.py @@ -6,18 +6,20 @@ This module implements Eve custom exceptions. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ class ConfigException(Exception): - """ Raised when errors are found in the configuration settings (usually + """Raised when errors are found in the configuration settings (usually `settings.py`). """ + pass class SchemaException(ConfigException): - """ Raised when errors are found in a field schema definition """ + """Raised when errors are found in a field schema definition""" + pass diff --git a/eve/flaskapp.py b/eve/flaskapp.py index d603fd3ff..73641e9ec 100644 --- a/eve/flaskapp.py +++ b/eve/flaskapp.py @@ -6,48 +6,56 @@ This module implements the central WSGI application object as a Flask subclass. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ +import copy +import fnmatch import os import sys +import warnings +from typing import TYPE_CHECKING -import copy from events import Events from flask import Flask from werkzeug.routing import BaseConverter from werkzeug.serving import WSGIRequestHandler import eve -from eve.defaults import build_defaults -from eve.endpoints import collections_endpoint, item_endpoint, home_endpoint, \ - error_endpoint, media_endpoint, schema_collection_endpoint, \ - schema_item_endpoint +from eve import default_settings +from eve.endpoints import (collections_endpoint, error_endpoint, home_endpoint, + item_endpoint, media_endpoint, + schema_collection_endpoint, schema_item_endpoint) from eve.exceptions import ConfigException, SchemaException -from eve.io.mongo import Mongo, Validator, GridFSMediaStorage, create_index +from eve.io.mongo import (GridFSMediaStorage, Mongo, Validator, + ensure_mongo_indexes) from eve.logging import RequestFilter from eve.utils import api_prefix, extract_key_values class EveWSGIRequestHandler(WSGIRequestHandler): - """ Extend werkzeug request handler to include current Eve version in all + """Extend werkzeug request handler to include current Eve version in all responses, which is super-handy for debugging. """ + @property def server_version(self): - return 'Eve/%s ' % eve.__version__ + super(EveWSGIRequestHandler, - self).server_version + return ( + "Eve/%s " % eve.__version__ + + super().server_version + ) class RegexConverter(BaseConverter): - """ Extend werkzeug routing by supporting regex for urls/API endpoints """ + """Extend werkzeug routing by supporting regex for urls/API endpoints""" + def __init__(self, url_map, *items): - super(RegexConverter, self).__init__(url_map) + super().__init__(url_map) self.regex = items[0] class Eve(Flask, Events): - """ The main Eve object. On initialization it will load Eve settings, then + """The main Eve object. On initialization it will load Eve settings, then configure and enable the API endpoints. The API is launched by executing the code below::: @@ -93,7 +101,7 @@ class Eve(Flask, Events): .. versionchanged:: 0.2 Support for additional Flask url converters. Support for optional, custom json encoder class. - Support for endpoint-level authenticatoin classes. + Support for endpoint-level authentication classes. New method Eve.register_resource() for registering new resource after initialization of Eve object. This is needed for simpler initialization API of all ORM/ODM extensions. @@ -112,22 +120,32 @@ class Eve(Flask, Events): .. versionchanged:: 0.0.4 'auth' argument added to handle authentication classes """ + #: Allowed methods for resource endpoints - supported_resource_methods = ['GET', 'POST', 'DELETE'] + supported_resource_methods = ["GET", "POST", "DELETE"] #: Allowed methods for item endpoints - supported_item_methods = ['GET', 'PATCH', 'DELETE', 'PUT'] - - def __init__(self, import_name=__package__, settings='settings.py', - validator=Validator, data=Mongo, auth=None, redis=None, - url_converters=None, json_encoder=None, - media=GridFSMediaStorage, **kwargs): - """ Eve main WSGI app is implemented as a Flask subclass. Since we want + supported_item_methods = ["GET", "PATCH", "DELETE", "PUT"] + + def __init__( + self, + import_name=__package__, + settings="settings.py", + validator=Validator, + data=Mongo, + auth=None, + redis=None, + url_converters=None, + json_encoder=None, + media=GridFSMediaStorage, + **kwargs + ): + """Eve main WSGI app is implemented as a Flask subclass. Since we want to be able to launch our API by simply invoking Flask's run() method, we need to enhance our super-class a little bit. """ - super(Eve, self).__init__(import_name, **kwargs) + super().__init__(import_name, **kwargs) # add support for request metadata to the log record self.logger.addFilter(RequestFilter()) @@ -139,7 +157,7 @@ def __init__(self, import_name=__package__, settings='settings.py', self.validate_domain_struct() # enable regex routing - self.url_map.converters['regex'] = RegexConverter + self.url_map.converters["regex"] = RegexConverter # optional url_converters and json encoder if url_converters: @@ -158,10 +176,13 @@ def __init__(self, import_name=__package__, settings='settings.py', self.auth = None self._init_url_rules() - self._init_media_endpoint() + + if self.config["RETURN_MEDIA_AS_URL"]: + self._init_media_endpoint() + self._init_schema_endpoint() - if self.config['OPLOG'] is True: + if self.config["OPLOG"] is True: self._init_oplog() # validate and set defaults for each resource @@ -170,7 +191,7 @@ def __init__(self, import_name=__package__, settings='settings.py', # further insertion of versioned resources do not # cause a RuntimeError due to the change of size of # the dict - domain_copy = copy.deepcopy(self.config['DOMAIN']) + domain_copy = copy.deepcopy(self.config["DOMAIN"]) for resource, settings in domain_copy.items(): self.register_resource(resource, settings) @@ -194,13 +215,13 @@ def run(self, host=None, port=None, debug=None, **options): :param options: the options to be forwarded to the underlying Werkzeug server. See :func:`werkzeug.serving.run_simple` for more - information. """ + information.""" - options.setdefault('request_handler', EveWSGIRequestHandler) - super(Eve, self).run(host, port, debug, **options) + options.setdefault("request_handler", EveWSGIRequestHandler) + super().run(host, port, debug, **options) def load_config(self): - """ API settings are loaded from standard python modules. First from + """API settings are loaded from standard python modules. First from `settings.py`(or alternative name/path passed as an argument) and then, when defined, from the file specified in the `EVE_SETTINGS` environment variable. @@ -218,7 +239,7 @@ def load_config(self): """ # load defaults - self.config.from_object('eve.default_settings') + self.config.from_object("eve.default_settings") # overwrite the defaults with custom user settings if isinstance(self.settings, dict): @@ -227,39 +248,87 @@ def load_config(self): if os.path.isabs(self.settings): pyfile = self.settings else: - abspath = os.path.abspath(os.path.dirname(sys.argv[0])) - pyfile = os.path.join(abspath, self.settings) + + def find_settings_file(file_name): + # check if we can locate the file from sys.argv[0] + abspath = os.path.abspath(os.path.dirname(sys.argv[0])) + settings_file = os.path.join(abspath, file_name) + if os.path.isfile(settings_file): + return settings_file + # try to find settings.py in one of the + # paths in sys.path + for p in sys.path: + for root, dirs, files in os.walk(p): + for f in fnmatch.filter(files, file_name): + if os.path.isfile(os.path.join(root, f)): + return os.path.join(root, file_name) + + # try to load file from environment variable or settings.py + pyfile = find_settings_file( + os.environ.get("EVE_SETTINGS") or self.settings + ) + + if not pyfile: + raise IOError("Could not load settings.") + try: self.config.from_pyfile(pyfile) - except IOError: - # assume envvar is going to be used exclusively - pass - except: + except Exception: raise - # overwrite settings with custom environment variable - envvar = 'EVE_SETTINGS' - if os.environ.get(envvar): - self.config.from_envvar(envvar) - # flask-pymongo compatibility - self.config['MONGO_CONNECT'] = self.config['MONGO_OPTIONS'].get( - 'connect', True - ) + self.config["MONGO_CONNECT"] = self.config["MONGO_OPTIONS"].get("connect", True) + + self.check_deprecated_features() + + def check_deprecated_features(self): + """Method checks for usage of deprecated features.""" + + def deprecated_renderers_settings(): + """Checks if JSON or XML setting is still being used instead of + RENDERERS and if so, composes new settings. + """ + msg = ( + "{} setting is deprecated and will be removed" + " in future release. Please use RENDERERS instead." + ) + + if "JSON" in self.config or "XML" in self.config: + self.config["RENDERERS"] = default_settings.RENDERERS[:] + + if "JSON" in self.config: + warnings.warn(msg.format("JSON")) + if not self.config["JSON"]: + self.config["RENDERERS"].remove("eve.render.JSONRenderer") + + if "XML" in self.config: + warnings.warn(msg.format("XML")) + if not self.config["XML"]: + self.config["RENDERERS"].remove("eve.render.XMLRenderer") + + deprecated_renderers_settings() + + if self.config.get("JSONP_ARGUMENT"): + warnings.warn( + "JSONP_ARGUMENT is deprecated and will be removed in a future " + "release. Use CORS (Cross-Origin Resource Sharing) instead.", + DeprecationWarning, + stacklevel=2, + ) def validate_domain_struct(self): - """ Validates that Eve configuration settings conform to the + """Validates that Eve configuration settings conform to the requirements. """ try: - domain = self.config['DOMAIN'] - except: - raise ConfigException('DOMAIN dictionary missing or wrong.') + domain = self.config["DOMAIN"] + except Exception: + raise ConfigException("DOMAIN dictionary missing or wrong.") if not isinstance(domain, dict): - raise ConfigException('DOMAIN must be a dict.') + raise ConfigException("DOMAIN must be a dict.") def validate_config(self): - """ Makes sure that REST methods expressed in the configuration + """Makes sure that REST methods expressed in the configuration settings are supported. .. versionchanged:: 0.2.0 @@ -276,21 +345,23 @@ def validate_config(self): Support for DELETE resource method. """ # make sure that global resource methods are supported. - self.validate_methods(self.supported_resource_methods, - self.config.get('RESOURCE_METHODS'), - 'resource') + self.validate_methods( + self.supported_resource_methods, + self.config.get("RESOURCE_METHODS"), + "resource", + ) # make sure that global item methods are supported. - self.validate_methods(self.supported_item_methods, - self.config.get('ITEM_METHODS'), - 'item') + self.validate_methods( + self.supported_item_methods, self.config.get("ITEM_METHODS"), "item" + ) # make sure that individual resource/item methods are supported. - for resource, settings in self.config['DOMAIN'].items(): + for resource, settings in self.config["DOMAIN"].items(): self._validate_resource_settings(resource, settings) def _validate_resource_settings(self, resource, settings): - """ Validates one resource in configuration settings. + """Validates one resource in configuration settings. :param resource: name of the resource which settings refer to. :param settings: settings of resource to be validated. @@ -300,38 +371,48 @@ def _validate_resource_settings(self, resource, settings): .. versionadded:: 0.2 """ - self.validate_methods(self.supported_resource_methods, - settings['resource_methods'], - '[%s] resource ' % resource) - self.validate_methods(self.supported_item_methods, - settings['item_methods'], - '[%s] item ' % resource) + self.validate_methods( + self.supported_resource_methods, + settings["resource_methods"], + "[%s] resource " % resource, + ) + self.validate_methods( + self.supported_item_methods, + settings["item_methods"], + "[%s] item " % resource, + ) # while a resource schema is optional for read-only access, # it is mandatory for write-access to resource/items. - if 'POST' in settings['resource_methods'] or \ - 'PATCH' in settings['item_methods']: - if len(settings['schema']) == 0: - raise ConfigException('A resource schema must be provided ' - 'when POST or PATCH methods are allowed ' - 'for a resource [%s].' % resource) - - self.validate_roles('allowed_roles', settings, resource) - self.validate_roles('allowed_read_roles', settings, resource) - self.validate_roles('allowed_write_roles', settings, resource) - self.validate_roles('allowed_item_roles', settings, resource) - self.validate_roles('allowed_item_read_roles', settings, resource) - self.validate_roles('allowed_item_write_roles', settings, resource) - - if settings['auth_field'] == settings['id_field']: - raise ConfigException('"%s": auth_field cannot be set to id_field ' - '(%s)' % (resource, settings['id_field'])) - - self.validate_schema(resource, settings['schema']) + if ( + "POST" in settings["resource_methods"] + or "PATCH" in settings["item_methods"] + ): + if not settings["schema"]: + raise ConfigException( + "A resource schema must be provided " + "when POST or PATCH methods are allowed " + "for a resource [%s]." % resource + ) + + self.validate_roles("allowed_roles", settings, resource) + self.validate_roles("allowed_read_roles", settings, resource) + self.validate_roles("allowed_write_roles", settings, resource) + self.validate_roles("allowed_item_roles", settings, resource) + self.validate_roles("allowed_item_read_roles", settings, resource) + self.validate_roles("allowed_item_write_roles", settings, resource) + + if settings["auth_field"] == settings["id_field"]: + raise ConfigException( + '"%s": auth_field cannot be set to id_field ' + "(%s)" % (resource, settings["id_field"]) + ) + + self.validate_schema(resource, settings["schema"]) def validate_roles(self, directive, candidate, resource): - """ Validates that user role directives are syntactically and formally - adeguate. + """Validates that user role directives are syntactically and formally + adequate. :param directive: either 'allowed_[read_|write_]roles' or 'allow_item_[read_|write_]roles'. @@ -343,11 +424,10 @@ def validate_roles(self, directive, candidate, resource): """ roles = candidate[directive] if not isinstance(roles, list): - raise ConfigException("'%s' must be list" - "[%s]." % (directive, resource)) + raise ConfigException("'%s' must be list" "[%s]." % (directive, resource)) def validate_methods(self, allowed, proposed, item): - """ Compares allowed and proposed methods, raising a `ConfigException` + """Compares allowed and proposed methods, raising a `ConfigException` when they don't match. :param allowed: a list of supported (allowed) methods. @@ -357,13 +437,13 @@ def validate_methods(self, allowed, proposed, item): """ diff = set(proposed) - set(allowed) if diff: - raise ConfigException('Unallowed %s method(s): %s. ' - 'Supported: %s' % - (item, ', '.join(diff), - ', '.join(allowed))) + raise ConfigException( + "Unallowed %s method(s): %s. " + "Supported: %s" % (item, ", ".join(diff), ", ".join(allowed)) + ) def validate_schema(self, resource, schema): - """ Validates a resource schema. + """Validates a resource schema. :param resource: resource name. :param schema: schema definition for the resource. @@ -396,56 +476,64 @@ def validate_schema(self, resource, schema): Now collecting offending items in a list and inserting results into the exception message. """ + def validate_field_name(field): - forbidden = ['$', '.'] + forbidden = ["$", "."] if any(x in field for x in forbidden): raise SchemaException( - "Field '%s' cannot contain any of the following: '%s'." % - (field, ', '.join(forbidden))) + "Field '%s' cannot contain any of the following: '%s'." + % (field, ", ".join(forbidden)) + ) - resource_settings = self.config['DOMAIN'][resource] + resource_settings = self.config["DOMAIN"][resource] # ensure automatically handled fields aren't defined fields = [eve.DATE_CREATED, eve.LAST_UPDATED, eve.ETAG] - if resource_settings['versioning'] is True: + if resource_settings["versioning"] is True: fields += [ - self.config['VERSION'], - self.config['LATEST_VERSION'], - resource_settings['id_field'] + - self.config['VERSION_ID_SUFFIX']] - if resource_settings['soft_delete'] is True: - fields += [self.config['DELETED']] - - offenders = [] - for field in fields: - if field in schema: - offenders.append(field) + self.config["VERSION"], + self.config["LATEST_VERSION"], + resource_settings["id_field"] + self.config["VERSION_ID_SUFFIX"], + ] + if resource_settings["soft_delete"] is True: + fields += [self.config["DELETED"]] + + offenders = [field for field in fields if field in schema] if offenders: - raise SchemaException('field(s) "%s" not allowed in "%s" schema ' - '(they will be handled automatically).' - % (', '.join(offenders), resource)) + raise SchemaException( + 'field(s) "%s" not allowed in "%s" schema ' + "(they will be handled automatically)." + % (", ".join(offenders), resource) + ) + + if not isinstance(schema, dict): + return for field, ruleset in schema.items(): validate_field_name(field) - if 'dict' in ruleset.get('type', ''): - for field in ruleset.get('schema', {}).keys(): - validate_field_name(field) + if isinstance(ruleset, dict) and "dict" in ruleset.get("type", ""): + for field_ in ruleset.get("schema", {}): + validate_field_name(field_) # check data_relation rules - if 'data_relation' in ruleset: - if 'resource' not in ruleset['data_relation']: - raise SchemaException("'resource' key is mandatory for " - "the 'data_relation' rule in " - "'%s: %s'" % (resource, field)) - if ruleset['data_relation'].get('embeddable', False): + if "data_relation" in ruleset: + if "resource" not in ruleset["data_relation"]: + raise SchemaException( + "'resource' key is mandatory for " + "the 'data_relation' rule in " + "'%s: %s'" % (resource, field) + ) + if ruleset["data_relation"].get("embeddable", False): # special care for data_relations with a version - value_field = ruleset['data_relation']['field'] - if ruleset['data_relation'].get('version', False): - if 'schema' not in ruleset or \ - value_field not in ruleset['schema'] or \ - 'type' not in ruleset['schema'][value_field]: + value_field = ruleset["data_relation"]["field"] + if ruleset["data_relation"].get("version", False): + if ( + "schema" not in ruleset + or value_field not in ruleset["schema"] + or "type" not in ruleset["schema"][value_field] + ): raise SchemaException( "Must defined type for '%s' in schema when " "declaring an embedded data_relation with" @@ -455,7 +543,7 @@ def validate_field_name(field): # TODO are there other mandatory settings? Validate them here def set_defaults(self): - """ When not provided, fills individual resource settings with default + """When not provided, fills individual resource settings with default or global configuration settings. .. versionchanged:: 0.4 @@ -509,11 +597,14 @@ def set_defaults(self): `item_title` default value. """ - for resource, settings in self.config['DOMAIN'].items(): + for resource, settings in self.config["DOMAIN"].items(): self._set_resource_defaults(resource, settings) def _set_resource_defaults(self, resource, settings): - """ Low-level method which sets default values for one resource. + """Low-level method which sets default values for one resource. + + .. versionchanged:: 1.1.0 + Added 'mongo_query_whitelist'. .. versionchanged:: 0.6.2 Fix: startup crash when both SOFT_DELETE and ALLOW_UNKNOWN are True. @@ -538,116 +629,99 @@ def _set_resource_defaults(self, resource, settings): 'resource_title', 'default_sort', 'embedded_fields'. - Support for endpoint-level authenticatoin classes. + Support for endpoint-level authentication classes. """ - settings.setdefault('url', resource) - settings.setdefault('resource_methods', - self.config['RESOURCE_METHODS']) - settings.setdefault('public_methods', - self.config['PUBLIC_METHODS']) - settings.setdefault('allowed_roles', self.config['ALLOWED_ROLES']) - settings.setdefault('allowed_read_roles', - self.config['ALLOWED_READ_ROLES']) - settings.setdefault('allowed_write_roles', - self.config['ALLOWED_WRITE_ROLES']) - settings.setdefault('cache_control', self.config['CACHE_CONTROL']) - settings.setdefault('cache_expires', self.config['CACHE_EXPIRES']) - - settings.setdefault('id_field', self.config['ID_FIELD']) - settings.setdefault('item_lookup_field', - self.config['ITEM_LOOKUP_FIELD']) - settings.setdefault('item_url', self.config['ITEM_URL']) - settings.setdefault('resource_title', settings['url']) - settings.setdefault('item_title', - resource.rstrip('s').capitalize()) - settings.setdefault('item_lookup', self.config['ITEM_LOOKUP']) - settings.setdefault('public_item_methods', - self.config['PUBLIC_ITEM_METHODS']) - settings.setdefault('allowed_item_roles', - self.config['ALLOWED_ITEM_ROLES']) - settings.setdefault('allowed_item_read_roles', - self.config['ALLOWED_ITEM_READ_ROLES']) - settings.setdefault('allowed_item_write_roles', - self.config['ALLOWED_ITEM_WRITE_ROLES']) - settings.setdefault('allowed_filters', - self.config['ALLOWED_FILTERS']) - settings.setdefault('sorting', self.config['SORTING']) - settings.setdefault('embedding', self.config['EMBEDDING']) - settings.setdefault('embedded_fields', []) - settings.setdefault('pagination', self.config['PAGINATION']) - settings.setdefault('projection', self.config['PROJECTION']) - settings.setdefault('versioning', self.config['VERSIONING']) - settings.setdefault('soft_delete', self.config['SOFT_DELETE']) - settings.setdefault('bulk_enabled', self.config['BULK_ENABLED']) - settings.setdefault('internal_resource', - self.config['INTERNAL_RESOURCE']) - settings.setdefault('etag_ignore_fields', None) + settings.setdefault("url", resource) + settings.setdefault("resource_methods", self.config["RESOURCE_METHODS"]) + settings.setdefault("public_methods", self.config["PUBLIC_METHODS"]) + settings.setdefault("allowed_roles", self.config["ALLOWED_ROLES"]) + settings.setdefault("allowed_read_roles", self.config["ALLOWED_READ_ROLES"]) + settings.setdefault("allowed_write_roles", self.config["ALLOWED_WRITE_ROLES"]) + settings.setdefault("cache_control", self.config["CACHE_CONTROL"]) + settings.setdefault("cache_expires", self.config["CACHE_EXPIRES"]) + + settings.setdefault("id_field", self.config["ID_FIELD"]) + settings.setdefault("item_lookup_field", self.config["ITEM_LOOKUP_FIELD"]) + settings.setdefault("item_url", self.config["ITEM_URL"]) + settings.setdefault("resource_title", settings["url"]) + settings.setdefault("item_title", resource.rstrip("s").capitalize()) + settings.setdefault("item_lookup", self.config["ITEM_LOOKUP"]) + settings.setdefault("public_item_methods", self.config["PUBLIC_ITEM_METHODS"]) + settings.setdefault("allowed_item_roles", self.config["ALLOWED_ITEM_ROLES"]) + settings.setdefault( + "allowed_item_read_roles", self.config["ALLOWED_ITEM_READ_ROLES"] + ) + settings.setdefault( + "allowed_item_write_roles", self.config["ALLOWED_ITEM_WRITE_ROLES"] + ) + settings.setdefault("allowed_filters", self.config["ALLOWED_FILTERS"]) + settings.setdefault("sorting", self.config["SORTING"]) + settings.setdefault("embedding", self.config["EMBEDDING"]) + settings.setdefault("embedded_fields", []) + settings.setdefault("pagination", self.config["PAGINATION"]) + settings.setdefault("projection", self.config["PROJECTION"]) + settings.setdefault("versioning", self.config["VERSIONING"]) + settings.setdefault("soft_delete", self.config["SOFT_DELETE"]) + settings.setdefault("bulk_enabled", self.config["BULK_ENABLED"]) + settings.setdefault("internal_resource", self.config["INTERNAL_RESOURCE"]) + settings.setdefault("etag_ignore_fields", None) # TODO make sure that this we really need the test below - if settings['item_lookup']: - item_methods = self.config['ITEM_METHODS'] + if settings["item_lookup"]: + item_methods = self.config["ITEM_METHODS"] else: item_methods = eve.ITEM_METHODS - settings.setdefault('item_methods', item_methods) - settings.setdefault('auth_field', - self.config['AUTH_FIELD']) - settings.setdefault('allow_unknown', self.config['ALLOW_UNKNOWN']) - settings.setdefault('transparent_schema_rules', - self.config['TRANSPARENT_SCHEMA_RULES']) - settings.setdefault('extra_response_fields', - self.config['EXTRA_RESPONSE_FIELDS']) - settings.setdefault('mongo_write_concern', - self.config['MONGO_WRITE_CONCERN']) - settings.setdefault('mongo_indexes', {}) - settings.setdefault('hateoas', - self.config['HATEOAS']) - settings.setdefault('authentication', self.auth if self.auth else None) + settings.setdefault("item_methods", item_methods) + settings.setdefault("auth_field", self.config["AUTH_FIELD"]) + settings.setdefault("allow_unknown", self.config["ALLOW_UNKNOWN"]) + settings.setdefault( + "extra_response_fields", self.config["EXTRA_RESPONSE_FIELDS"] + ) + settings.setdefault( + "mongo_query_whitelist", self.config["MONGO_QUERY_WHITELIST"] + ) + settings.setdefault("mongo_write_concern", self.config["MONGO_WRITE_CONCERN"]) + settings.setdefault("mongo_indexes", {}) + settings.setdefault("hateoas", self.config["HATEOAS"]) + settings.setdefault("authentication", self.auth if self.auth else None) + settings.setdefault( + "merge_nested_documents", self.config["MERGE_NESTED_DOCUMENTS"] + ) + settings.setdefault( + "normalize_dotted_fields", self.config["NORMALIZE_DOTTED_FIELDS"] + ) + settings.setdefault("normalize_on_patch", self.config["NORMALIZE_ON_PATCH"]) + settings.setdefault("optimize_pagination_for_speed", self.config["OPTIMIZE_PAGINATION_FOR_SPEED"]) # empty schemas are allowed for read-only access to resources - schema = settings.setdefault('schema', {}) - self.set_schema_defaults(schema, settings['id_field']) - - # 'defaults' helper set contains the names of fields with default - # values in their schema definition. - - # TODO support default values for embedded documents. - settings['defaults'] = build_defaults(schema) - - # list of all media fields for the resource - settings['_media'] = [field for field, definition in schema.items() if - definition.get('type') == 'media'] - - if settings['_media'] and not self.media: - raise ConfigException('A media storage class of type ' - ' eve.io.media.MediaStorage but be defined ' - 'for "media" fields to be properly stored.') + schema = settings.setdefault("schema", {}) + self.set_schema_defaults(schema, settings["id_field"]) self._set_resource_datasource(resource, schema, settings) def _set_resource_datasource(self, resource, schema, settings): - """ Set the default values for the resource 'datasource' setting. + """Set the default values for the resource 'datasource' setting. .. versionadded:: 0.7 """ - settings.setdefault('datasource', {}) + settings.setdefault("datasource", {}) - ds = settings['datasource'] - ds.setdefault('source', resource) - ds.setdefault('filter', None) - ds.setdefault('default_sort', None) + ds = settings["datasource"] + ds.setdefault("source", resource) + ds.setdefault("filter", None) + ds.setdefault("default_sort", None) self._set_resource_projection(ds, schema, settings) - - aggregation = ds.setdefault('aggregation', None) + aggregation = ds.setdefault("aggregation", None) if aggregation: - aggregation.setdefault('options', {}) + aggregation.setdefault("options", {}) # endpoints serving aggregation queries are read-only and do not # support item lookup. - settings['resource_methods'] = ['GET'] - settings['item_lookup'] = False + settings["resource_methods"] = ["GET"] + settings["item_lookup"] = False def _set_resource_projection(self, ds, schema, settings): - """ Set datasource projection for a resource + """Set datasource projection for a resource .. versionchanged:: 0.6.3 Fix: If datasource source is specified no fields are included by @@ -655,59 +729,75 @@ def _set_resource_projection(self, ds, schema, settings): .. versionadded:: 0.6.2 """ - - projection = ds.get('projection', {}) - - # check if any exclusion projection is defined - exclusion = any(((k, v) for k, v in projection.items() if v == 0)) \ - if projection else None - - # If no exclusion projection is defined, enhance the projection - # with automatic fields. Using both inclusion and exclusion will - # be rejected by Mongo - if not exclusion and len(schema) and \ - settings['allow_unknown'] is False: - if not projection: - projection.update(dict((field, 1) for (field) in schema)) - + # get existing or empty projection setting + projection = ds.get("projection", {}) + + # If exclusion projections are defined, they are use for + # concealing fields (rather than actual mongo exlusions). + # If inclusion projections are defined, exclusion projections are + # just ignored. + # Enhance the projection with automatic fields. + if schema and settings["allow_unknown"] is False: + inclusion_projection = dict( + [(k, v) for k, v in projection.items() if v == 1] + ) + exclusion_projection = dict( + [(k, v) for k, v in projection.items() if v == 0] + ) + # if inclusion project is empty, add all fields not excluded + if not inclusion_projection: + projection.update( + dict( + (field, 1) + for field in schema + if field not in exclusion_projection + ) + ) # enable retrieval of actual schema fields only. Eventual db # fields not included in the schema won't be returned. # despite projection, automatic fields are always included. - projection[settings['id_field']] = 1 - projection[self.config['LAST_UPDATED']] = 1 - projection[self.config['DATE_CREATED']] = 1 - projection[self.config['ETAG']] = 1 - if settings['versioning'] is True: - projection[self.config['VERSION']] = 1 - projection[ - settings['id_field'] + - self.config['VERSION_ID_SUFFIX']] = 1 - else: - # all fields are returned. - projection = None - ds.setdefault('projection', projection) + projection[settings["id_field"]] = 1 + projection[self.config["LAST_UPDATED"]] = 1 + projection[self.config["DATE_CREATED"]] = 1 + projection[self.config["ETAG"]] = 1 + if settings["versioning"] is True: + projection[self.config["VERSION"]] = 1 + projection[settings["id_field"] + self.config["VERSION_ID_SUFFIX"]] = 1 - if settings['soft_delete'] is True and not exclusion and \ - ds['projection'] is not None: - ds['projection'][self.config['DELETED']] = 1 + ds.setdefault("projection", projection) - # 'defaults' helper set contains the names of fields with default - # values in their schema definition. + if settings["soft_delete"] is True and projection: + projection[self.config["DELETED"]] = 1 - # TODO support default values for embedded documents. - settings['defaults'] = build_defaults(schema) + # set projection and projection is always a dictionary + ds["projection"] = projection # list of all media fields for the resource - settings['_media'] = [field for field, definition in schema.items() if - definition.get('type') == 'media'] + if isinstance(schema, dict): + settings["_media"] = [ + field + for field, definition in schema.items() + if isinstance(definition, dict) + and ( + definition.get("type") == "media" + or ( + definition.get("type") == "list" + and definition.get("schema", {}).get("type") == "media" + ) + ) + ] + else: + settings["_media"] = [] - if settings['_media'] and not self.media: - raise ConfigException('A media storage class of type ' - ' eve.io.media.MediaStorage must be defined ' - 'for "media" fields to be properly stored.') + if settings["_media"] and not self.media: + raise ConfigException( + "A media storage class of type " + " eve.io.media.MediaStorage must be defined " + 'for "media" fields to be properly stored.' + ) def set_schema_defaults(self, schema, id_field): - """ When not provided, fills individual schema settings with default + """When not provided, fills individual schema settings with default or global configuration settings. :param schema: the resource schema to be initialized with default @@ -728,24 +818,24 @@ def set_schema_defaults(self, schema, id_field): # DuplicateKeyConflict in the mongo layer. This also # avoids a performance hit (with 'unique' rule set, we would # end up with an extra db loopback on every insert). - schema.setdefault(id_field, {'type': 'objectid'}) + if isinstance(schema, dict): + schema.setdefault(id_field, {"type": "objectid"}) # set default 'field' value for all 'data_relation' rulesets, however # nested - for data_relation in list(extract_key_values('data_relation', schema)): - data_relation.setdefault('field', id_field) + for data_relation in list(extract_key_values("data_relation", schema)): + data_relation.setdefault("field", id_field) @property def api_prefix(self): - """ Prefix to API endpoints. + """Prefix to API endpoints. .. versionadded:: 0.2 """ - return api_prefix(self.config['URL_PREFIX'], - self.config['API_VERSION']) + return api_prefix(self.config["URL_PREFIX"], self.config["API_VERSION"]) def _add_resource_url_rules(self, resource, settings): - """ Builds the API url map for one resource. Methods are enabled for + """Builds the API url map for one resource. Methods are enabled for each mapped endpoint, as configured in the settings. .. versionchanged:: 0.5 @@ -754,56 +844,72 @@ def _add_resource_url_rules(self, resource, settings): .. versionadded:: 0.2 """ - self.config['SOURCES'][resource] = settings['datasource'] + self.config["SOURCES"][resource] = settings["datasource"] - if settings['internal_resource']: + if settings["internal_resource"]: return - url = '%s/%s' % (self.api_prefix, settings['url']) + url = "%s/%s" % (self.api_prefix, settings["url"]) - pretty_url = settings['url'] - if '<' in pretty_url: - pretty_url = pretty_url[:pretty_url.index('<') + 1] + \ - pretty_url[pretty_url.rindex(':') + 1:] - self.config['URLS'][resource] = pretty_url + pretty_url = settings["url"] + if "<" in pretty_url: + pretty_url = ( + pretty_url[: pretty_url.index("<") + 1] + + pretty_url[pretty_url.rindex(":") + 1 :] + ) + self.config["URLS"][resource] = pretty_url # resource endpoint endpoint = resource + "|resource" - self.add_url_rule(url, endpoint, view_func=collections_endpoint, - methods=settings['resource_methods'] + ['OPTIONS']) + self.add_url_rule( + url, + endpoint, + view_func=collections_endpoint, + methods=settings["resource_methods"] + ["OPTIONS"], + ) # item endpoint - if settings['item_lookup']: - item_url = '%s/<%s:%s>' % (url, settings['item_url'], - settings['item_lookup_field']) + if settings["item_lookup"]: + item_url = "%s/<%s:%s>" % ( + url, + settings["item_url"], + settings["item_lookup_field"], + ) endpoint = resource + "|item_lookup" - self.add_url_rule(item_url, endpoint, - view_func=item_endpoint, - methods=settings['item_methods'] + ['OPTIONS']) - if 'PATCH' in settings['item_methods']: - # support for POST with X-HTTM-Method-Override header for + self.add_url_rule( + item_url, + endpoint, + view_func=item_endpoint, + methods=settings["item_methods"] + ["OPTIONS"], + ) + if "PATCH" in settings["item_methods"]: + # support for POST with X-HTTP-Method-Override header for # clients not supporting PATCH. Also see item_endpoint() in # endpoints.py endpoint = resource + "|item_post_override" - self.add_url_rule(item_url, endpoint, view_func=item_endpoint, - methods=['POST']) + self.add_url_rule( + item_url, endpoint, view_func=item_endpoint, methods=["POST"] + ) # also enable an alternative lookup/endpoint if allowed - lookup = settings.get('additional_lookup') + lookup = settings.get("additional_lookup") if lookup: - l_type = settings['schema'][lookup['field']]['type'] - if l_type == 'integer': - item_url = '%s/' % (url, lookup['field']) + l_type = settings["schema"][lookup["field"]]["type"] + if l_type == "integer": + item_url = "%s/" % (url, lookup["field"]) else: - item_url = '%s/<%s:%s>' % (url, lookup['url'], - lookup['field']) + item_url = "%s/<%s:%s>" % (url, lookup["url"], lookup["field"]) endpoint = resource + "|item_additional_lookup" - self.add_url_rule(item_url, endpoint, view_func=item_endpoint, - methods=['GET', 'OPTIONS']) + self.add_url_rule( + item_url, + endpoint, + view_func=item_endpoint, + methods=["GET", "OPTIONS"], + ) def _init_url_rules(self): - """ Builds the API url map. Methods are enabled for each mapped + """Builds the API url map. Methods are enabled for each mapped endpoint, as configured in the settings. .. versionchanged:: 0.4 @@ -833,8 +939,8 @@ def _init_url_rules(self): Support for API_VERSION as an endpoint prefix. """ # helpers - self.config['URLS'] = {} # maps resources to urls - self.config['SOURCES'] = {} # maps resources to their datasources + self.config["URLS"] = {} # maps resources to urls + self.config["SOURCES"] = {} # maps resources to their datasources # we choose not to care about trailing slashes at all. # Both '/resource/' and '/resource' will work, same with @@ -842,11 +948,15 @@ def _init_url_rules(self): self.url_map.strict_slashes = False # home page (API entry point) - self.add_url_rule('%s/' % self.api_prefix, 'home', - view_func=home_endpoint, methods=['GET', 'OPTIONS']) + self.add_url_rule( + "%s/" % self.api_prefix, + "home", + view_func=home_endpoint, + methods=["GET", "OPTIONS"], + ) def register_resource(self, resource, settings): - """ Registers new resource to the domain. + """Registers new resource to the domain. Under the hood this validates given settings, updates default values and adds necessary URL routes (builds api url map). @@ -868,7 +978,7 @@ def register_resource(self, resource, settings): # this line only makes sense when we call this function outside of the # standard Eve setup routine, but it doesn't hurt to still call it - self.config['DOMAIN'][resource] = settings + self.config["DOMAIN"][resource] = settings # set up resource self._set_resource_defaults(resource, settings) @@ -876,52 +986,49 @@ def register_resource(self, resource, settings): self._add_resource_url_rules(resource, settings) # add rules for version control collections if appropriate - if settings['versioning'] is True: - versioned_resource = resource + self.config['VERSIONS'] - self.config['DOMAIN'][versioned_resource] = \ - copy.deepcopy(self.config['DOMAIN'][resource]) - self.config['DOMAIN'][versioned_resource]['datasource']['source'] \ - += self.config['VERSIONS'] - self.config['SOURCES'][versioned_resource] = \ - copy.deepcopy(self.config['SOURCES'][resource]) - self.config['SOURCES'][versioned_resource]['source'] += \ - self.config['VERSIONS'] + if settings["versioning"] is True: + versioned_resource = resource + self.config["VERSIONS"] + self.config["DOMAIN"][versioned_resource] = copy.deepcopy( + self.config["DOMAIN"][resource] + ) + self.config["DOMAIN"][versioned_resource]["datasource"][ + "source" + ] += self.config["VERSIONS"] + self.config["SOURCES"][versioned_resource] = copy.deepcopy( + self.config["SOURCES"][resource] + ) + self.config["SOURCES"][versioned_resource]["source"] += self.config[ + "VERSIONS" + ] # the new versioned resource also needs URL rules self._add_resource_url_rules( - versioned_resource, - self.config['DOMAIN'][versioned_resource] + versioned_resource, self.config["DOMAIN"][versioned_resource] ) # create the mongo db indexes - mongo_indexes = self.config['DOMAIN'][resource]['mongo_indexes'] - if mongo_indexes: - for name, value in mongo_indexes.items(): - if isinstance(value, tuple): - list_of_keys, index_options = value - else: - list_of_keys = value - index_options = {} - - create_index(self, resource, name, list_of_keys, index_options) + ensure_mongo_indexes(self, resource) # flask-pymongo compatibility. - if 'MONGO_OPTIONS' in self.config['DOMAIN']: - connect = self.config['DOMAIN']['MONGO_OPTIONS'].get( - 'connect', True - ) - self.config['DOMAIN']['MONGO_CONNECT'] = connect + if "MONGO_OPTIONS" in self.config["DOMAIN"]: + connect = self.config["DOMAIN"]["MONGO_OPTIONS"].get("connect", True) + self.config["DOMAIN"]["MONGO_CONNECT"] = connect def register_error_handlers(self): - """ Register custom error handlers so we make sure that all errors + """Register custom error handlers so we make sure that all errors return a parseable body. + .. versionchanged: 0.6.5 + Replace obsolete app.register_error_handler_spec() with + register_error_handler(), which works with Flask>=0.11.1. Closes + #904, #945. + .. versionadded:: 0.4 """ - for code in self.config['STANDARD_ERRORS']: - self.error_handler_spec[None][code] = error_endpoint + for code in self.config["STANDARD_ERRORS"]: + self.register_error_handler(code, error_endpoint) def _init_oplog(self): - """ If enabled, configures the OPLOG endpoint. + """If enabled, configures the OPLOG endpoint. .. versionchanged:: 0.7 Add 'u' field to oplog audit schema. See #846. @@ -929,83 +1036,82 @@ def _init_oplog(self): .. versionadded:: 0.5 """ name, endpoint, audit, extra = ( - self.config['OPLOG_NAME'], - self.config['OPLOG_ENDPOINT'], - self.config['OPLOG_AUDIT'], - self.config['OPLOG_RETURN_EXTRA_FIELD'] + self.config["OPLOG_NAME"], + self.config["OPLOG_ENDPOINT"], + self.config["OPLOG_AUDIT"], + self.config["OPLOG_RETURN_EXTRA_FIELD"], ) - settings = self.config['DOMAIN'].setdefault(name, {}) + settings = self.config["DOMAIN"].setdefault(name, {}) - settings.setdefault('datasource', {'source': name}) + settings.setdefault("datasource", {"source": name}) # this endpoint is always read-only - settings['resource_methods'] = ['GET'] - settings['item_methods'] = ['GET'] + settings["resource_methods"] = ["GET"] + settings["item_methods"] = ["GET"] if endpoint: - settings.setdefault('url', endpoint) - settings['internal_resource'] = False + settings.setdefault("url", endpoint) + settings["internal_resource"] = False else: # make it an internal resource - settings['url'] = name - settings['internal_resource'] = True + settings["url"] = name + settings["internal_resource"] = True # schema is also fixed. it is needed because otherwise we # would end up exposing the AUTH_FIELD when User-Restricted- # Resource-Access is enabled. - settings['schema'] = { - 'r': {}, - 'o': {}, - 'i': {}, - } + settings["schema"] = {"r": {}, "o": {}, "i": {}} if extra: - settings['schema'].update( - {'extra': {}} - ) + settings["schema"].update({"extra": {}}) if audit: - settings['schema'].update( - { - 'ip': {}, - 'c': {}, - 'u': {}, - } - ) + settings["schema"].update({"ip": {}, "c": {}, "u": {}}) def _init_media_endpoint(self): - endpoint = self.config['MEDIA_ENDPOINT'] + endpoint = self.config["MEDIA_ENDPOINT"] if endpoint: - media_url = '%s/%s/<%s:_id>' % (self.api_prefix, - endpoint, - self.config['MEDIA_URL']) - self.add_url_rule(media_url, 'media', - view_func=media_endpoint, methods=['GET']) + media_url = "%s/%s/<%s:_id>" % ( + self.api_prefix, + endpoint, + self.config["MEDIA_URL"], + ) + self.add_url_rule( + media_url, "media", view_func=media_endpoint, methods=["GET", "OPTIONS"] + ) def _init_schema_endpoint(self): - """Configures the schema endpoint if set in configuration. - """ - endpoint = self.config['SCHEMA_ENDPOINT'] + """Configures the schema endpoint if set in configuration.""" + endpoint = self.config["SCHEMA_ENDPOINT"] if endpoint: - schema_url = '%s/%s' % (self.api_prefix, endpoint) + schema_url = "%s/%s" % (self.api_prefix, endpoint) # add schema collections url - self.add_url_rule(schema_url, 'schema_collection', - view_func=schema_collection_endpoint, - methods=['GET', 'OPTIONS']) + self.add_url_rule( + schema_url, + "schema_collection", + view_func=schema_collection_endpoint, + methods=["GET", "OPTIONS"], + ) # add schema item url - self.add_url_rule(schema_url + '/', 'schema_item', - view_func=schema_item_endpoint, - methods=['GET', 'OPTIONS']) + self.add_url_rule( + schema_url + "/", + "schema_item", + view_func=schema_item_endpoint, + methods=["GET", "OPTIONS"], + ) def __call__(self, environ, start_response): - """ If HTTP_X_METHOD_OVERRIDE is included with the request and method + """If HTTP_X_METHOD_OVERRIDE is included with the request and method override is allowed, make sure the override method is returned to Eve as the request method, so normal routing and method validation can be performed. """ - if self.config['ALLOW_OVERRIDE_HTTP_METHOD']: - environ['REQUEST_METHOD'] = environ.get( - 'HTTP_X_HTTP_METHOD_OVERRIDE', - environ['REQUEST_METHOD']).upper() - return super(Eve, self).__call__(environ, start_response) + if self.config["ALLOW_OVERRIDE_HTTP_METHOD"]: + environ["REQUEST_METHOD"] = environ.get( + "HTTP_X_HTTP_METHOD_OVERRIDE", environ["REQUEST_METHOD"] + ).upper() + return super().__call__(environ, start_response) + + if TYPE_CHECKING: + def __setattr__(self, name, value): ... diff --git a/eve/io/__init__.py b/eve/io/__init__.py index 04930df52..0e7b00b66 100644 --- a/eve/io/__init__.py +++ b/eve/io/__init__.py @@ -6,9 +6,9 @@ This package implements the data layers supported by Eve. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ # flake8: noqa -from eve.io.base import DataLayer, ConnectionException +from eve.io.base import ConnectionException, DataLayer diff --git a/eve/io/base.py b/eve/io/base.py index b620af62f..76ba7cee8 100644 --- a/eve/io/base.py +++ b/eve/io/base.py @@ -6,56 +6,61 @@ Standard interface implemented by Eve data layers. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ import datetime -import simplejson as json from copy import copy -from flask import request, abort -from eve.utils import date_to_str + +import simplejson as json +from flask import abort, request + from eve.auth import auth_field_and_value -from eve.utils import config, auto_fields, debug_error_message +from eve.utils import auto_fields, config, date_to_str, debug_error_message class BaseJSONEncoder(json.JSONEncoder): - """ Proprietary JSONEconder subclass used by the json render function. + """Proprietary JSONEconder subclass used by the json render function. This is needed to address the encoding of special values. """ + def default(self, obj): if isinstance(obj, datetime.datetime): # convert any datetime to RFC 1123 format return date_to_str(obj) - elif isinstance(obj, (datetime.time, datetime.date)): + if isinstance(obj, (datetime.time, datetime.date)): # should not happen since the only supported date-like format # supported at dmain schema level is 'datetime' . return obj.isoformat() - elif isinstance(obj, set): + if isinstance(obj, set): # convert set objects to encodable lists return list(obj) return json.JSONEncoder.default(self, obj) class ConnectionException(Exception): - """ Raised when DataLayer subclasses cannot find/activate to their + """Raised when DataLayer subclasses cannot find/activate to their database connection. :param driver_exception: the original exception raised by the source db driver """ + def __init__(self, driver_exception=None): self.driver_exception = driver_exception def __str__(self): - msg = ("Error initializing the driver. Make sure the database server" - "is running. ") + msg = ( + "Error initializing the driver. Make sure the database server" + "is running. " + ) if self.driver_exception: msg += "Driver exception: %s" % repr(self.driver_exception) return msg -class DataLayer(object): - """ Base data layer class. Defines the interface that actual data-access +class DataLayer(): + """Base data layer class. Defines the interface that actual data-access classes, being subclasses, must implement. Implemented as a Flask extension. @@ -95,7 +100,7 @@ class OriginalChangedError(Exception): json_encoder_class = BaseJSONEncoder def __init__(self, app): - """ Implements the Flask extension pattern. + """Implements the Flask extension pattern. .. versionchanged:: 0.2 Explicit initialize self.driver to None. @@ -108,13 +113,13 @@ def __init__(self, app): self.app = None def init_app(self, app): - """ This is where you want to initialize the db driver so it will be + """This is where you want to initialize the db driver so it will be alive through the whole instance lifespan. """ raise NotImplementedError - def find(self, resource, req, sub_resource_lookup): - """ Retrieves a set of documents (rows), matching the current request. + def find(self, resource, req, sub_resource_lookup, perform_count=True): + """Retrieves a set of documents (rows), matching the current request. Consumed when a request hits a collection/document endpoint (`/people/`). @@ -125,11 +130,13 @@ def find(self, resource, req, sub_resource_lookup): :param req: an instance of ``eve.utils.ParsedRequest``. This contains all the constraints that must be fulfilled in order to satisfy the original request (where and sort parts, paging, - etc). Be warned that `where` and `sort` expresions will + etc). Be warned that `where` and `sort` expressions will need proper parsing, according to the syntax that you want to support with your driver. For example ``eve.io.Mongo`` supports both Python and Mongo-like query syntaxes. :param sub_resource_lookup: sub-resource lookup from the endpoint url. + :param perform_count: whether a document count should be performed and + returned to the client. .. versionchanged:: 0.3 Support for sub-resources. @@ -137,7 +144,7 @@ def find(self, resource, req, sub_resource_lookup): raise NotImplementedError def aggregate(self, resource, pipeline, options): - """ Perform an aggregation on the resource datasource and returns + """Perform an aggregation on the resource datasource and returns the result. Only implent this if the underlying db engine supports aggregation operations. @@ -151,8 +158,16 @@ def aggregate(self, resource, pipeline, options): """ raise NotImplementedError - def find_one(self, resource, req, **lookup): - """ Retrieves a single document/record. Consumed when a request hits an + def find_one( + self, + resource, + req, + check_auth_value=True, + force_auth_field_projection=False, + mongo_options=None, + **lookup + ): + """Retrieves a single document/record. Consumed when a request hits an item endpoint (`/people/id/`). :param resource: resource being accessed. You should then use the @@ -164,7 +179,16 @@ def find_one(self, resource, req, **lookup): etc). As we are going to only look for one document here, the only req attribute that you want to process here is ``req.projection``. - + :param check_auth_value: a boolean flag indicating if the find + operation should consider user-restricted + resource access. Defaults to ``True``. + :param force_auth_field_projection: a boolean flag indicating if the + find operation should always + include the user-restricted + resource access field (if + configured). Defaults to ``False``. + :param mongo_options: options to pass to PyMongo. e.g. read_preferences + of the initial get. :param **lookup: the lookup fields. This will most likely be a record id or, if alternate lookup is supported by the API, the corresponding query. @@ -175,20 +199,20 @@ def find_one(self, resource, req, **lookup): """ raise NotImplementedError - def find_one_raw(self, resource, _id): - """ Retrieves a single, raw document. No projections or datasource - filters are being applied here. Just looking up the document by unique - id. + def find_one_raw(self, resource, **lookup): + """Retrieves a single, raw document. No projections or datasource + filters are being applied here. Just looking up the document using the + same lookup. :param resource: resource name. - :param id: unique id. + :param ** lookup: lookup query. .. versionadded:: 0.4 """ raise NotImplementedError def find_list_of_ids(self, resource, ids, client_projection=None): - """ Retrieves a list of documents based on a list of primary keys + """Retrieves a list of documents based on a list of primary keys The primary key is the field defined in `ID_FIELD`. This is a separate function to allow us to use per-database optimizations for this type of query. @@ -205,7 +229,7 @@ def find_list_of_ids(self, resource, ids, client_projection=None): raise NotImplementedError def insert(self, resource, doc_or_docs): - """ Inserts a document into a resource collection/table. + """Inserts a document into a resource collection/table. :param resource: resource being accessed. You should then use the ``datasource`` helper function to retrieve both @@ -220,7 +244,7 @@ def insert(self, resource, doc_or_docs): raise NotImplementedError def update(self, resource, id_, updates, original): - """ Updates a collection/table document/row. + """Updates a collection/table document/row. :param resource: resource being accessed. You should then use the ``datasource`` helper function to retrieve the actual datasource name. @@ -235,7 +259,7 @@ def update(self, resource, id_, updates, original): raise NotImplementedError def replace(self, resource, id_, document, original): - """ Replaces a collection/table document/row. + """Replaces a collection/table document/row. :param resource: resource being accessed. You should then use the ``datasource`` helper function to retrieve the actual datasource name. @@ -249,8 +273,8 @@ def replace(self, resource, id_, document, original): """ raise NotImplementedError - def remove(self, resource, lookup={}): - """ Removes a document/row or an entire set of documents/rows from a + def remove(self, resource, lookup): + """Removes a document/row or an entire set of documents/rows from a database collection/table. :param resource: resource being accessed. You should then use @@ -267,7 +291,7 @@ def remove(self, resource, lookup={}): raise NotImplementedError def combine_queries(self, query_a, query_b): - """ Takes two db queries and applies db-specific syntax to produce + """Takes two db queries and applies db-specific syntax to produce the intersection. .. versionadded: 0.1.0 @@ -276,7 +300,7 @@ def combine_queries(self, query_a, query_b): raise NotImplementedError def get_value_from_query(self, query, field_name): - """ Parses the given potentially-complex query and returns the value + """Parses the given potentially-complex query and returns the value being assigned to the field given in `field_name`. This mainly exists to deal with more complicated compound queries @@ -287,7 +311,7 @@ def get_value_from_query(self, query, field_name): raise NotImplementedError def query_contains_field(self, query, field_name): - """ For the specified field name, does the query contain it? + """For the specified field name, does the query contain it? Used know whether we need to parse a compound query. .. versionadded: 0.1.0 @@ -296,10 +320,10 @@ def query_contains_field(self, query, field_name): raise NotImplementedError def is_empty(self, resource): - """ Returns True if the collection is empty; False otherwise. While + """Returns True if the collection is empty; False otherwise. While a user could rely on self.find() method to achieve the same result, this method can probably take advantage of specific datastore features - to provide better perfomance. + to provide better performance. Don't forget, a 'resource' could have a pre-defined filter. If that is the case, it will have to be taken into consideration when performing @@ -314,7 +338,7 @@ def is_empty(self, resource): raise NotImplementedError def datasource(self, resource): - """ Returns a tuple with the actual name of the database + """Returns a tuple with the actual name of the database collection/table, base query and projection for the resource being accessed. @@ -335,16 +359,23 @@ def datasource(self, resource): """ dsource = config.SOURCES[resource] - source = copy(dsource['source']) - filter_ = copy(dsource['filter']) - sort = copy(dsource['default_sort']) - projection = copy(dsource['projection']) - - return source, filter_, projection, sort, - - def _datasource_ex(self, resource, query=None, client_projection=None, - client_sort=None): - """ Returns both db collection and exact query (base filter included) + source = copy(dsource["source"]) + filter_ = copy(dsource["filter"]) + sort = copy(dsource["default_sort"]) + projection = copy(dsource["projection"]) + + return source, filter_, projection, sort + + def _datasource_ex( + self, + resource, + query=None, + client_projection=None, + client_sort=None, + check_auth_value=True, + force_auth_field_projection=False, + ): + """Returns both db collection and exact query (base filter included) to which an API resource refers to. .. versionchanged:: 0.5.2 @@ -392,15 +423,13 @@ def _datasource_ex(self, resource, query=None, client_projection=None, """ datasource, filter_, projection_, sort_ = self.datasource(resource) - if client_sort: sort = client_sort else: # default sort is activated only if 'sorting' is enabled for the # resource. # TODO Consider raising a validation error on startup instead? - sort = sort_ if sort_ and config.DOMAIN[resource]['sorting'] else \ - None + sort = sort_ if sort_ and config.DOMAIN[resource]["sorting"] else None if filter_: if query: @@ -424,60 +453,68 @@ def _datasource_ex(self, resource, query=None, client_projection=None, # projection for the resource (avoid sniffing of private # fields) keep_fields = auto_fields(resource) - if 0 not in client_projection.values(): + if 1 in client_projection.values(): # inclusive projection - all values are 0 unless spec. or # auto - fields = dict([(field, field in keep_fields) for field in - fields.keys()]) + fields = dict( + [(field, field in keep_fields) for field in fields.keys()] + ) for field, value in client_projection.items(): - field_base = field.split('.')[0] + field_base = field.split(".")[0] if field_base not in keep_fields and field_base in fields: fields[field] = value - fields = dict([(field, 1) for field, value in fields.items() if - value]) else: # there's no standard projection so we assume we are in a # allow_unknown = True fields = client_projection + # always drop exclusion projection, thus avoid mixed projection not + # supported by db driver + fields = dict([(field, value) for field, value in fields.items() if value]) # If the current HTTP method is in `public_methods` or # `public_item_methods`, skip the `auth_field` check # Only inject the auth_field in the query when not creating new # documents. - if request and request.method not in ('POST', 'PUT'): + if ( + request + and request.method != "POST" + and (check_auth_value or force_auth_field_projection) + ): auth_field, request_auth_value = auth_field_and_value(resource) - if auth_field and request_auth_value: - if query: - # If the auth_field *replaces* a field in the query, - # and the values are /different/, deny the request - # This prevents the auth_field condition from - # overwriting the query (issue #77) - auth_field_in_query = \ - self.app.data.query_contains_field(query, auth_field) - if auth_field_in_query and \ - self.app.data.get_value_from_query( - query, auth_field) != request_auth_value: - abort(401, description='Incompatible User-Restricted ' - 'Resource request. ' - 'Request was for "%s"="%s" but `auth_field` ' - 'requires "%s"="%s".' % ( - auth_field, - self.app.data.get_value_from_query( - query, auth_field), - auth_field, - request_auth_value) - ) - else: - query = self.app.data.combine_queries( - query, {auth_field: request_auth_value} + if auth_field: + if request_auth_value and check_auth_value: + if query: + # If the auth_field *replaces* a field in the query, + # and the values are /different/, deny the request + # This prevents the auth_field condition from + # overwriting the query (issue #77) + auth_field_in_query = self.app.data.query_contains_field( + query, auth_field ) - else: - query = {auth_field: request_auth_value} + if ( + auth_field_in_query + and self.app.data.get_value_from_query(query, auth_field) + != request_auth_value + ): + desc = "Incompatible User-Restricted Resource " "request." + abort( + 401, + desc, + ("WWW-Authenticate", 'Basic realm="%s"' % __package__), + ) + else: + query = self.app.data.combine_queries( + query, {auth_field: request_auth_value} + ) + else: + query = {auth_field: request_auth_value} + if force_auth_field_projection: + fields[auth_field] = 1 return datasource, query, fields, sort def _client_projection(self, req): - """ Returns a properly parsed client projection if available. + """Returns a properly parsed client projection if available. :param req: a :class:`ParsedRequest` instance. @@ -491,10 +528,12 @@ def _client_projection(self, req): try: client_projection = json.loads(req.projection) if not isinstance(client_projection, dict): - raise Exception('The projection parameter has to be a ' - 'dict') - except: - abort(400, description=debug_error_message( - 'Unable to parse `projection` clause' - )) + raise Exception("The projection parameter has to be a " "dict") + except Exception: + abort( + 400, + description=debug_error_message( + "Unable to parse `projection` clause" + ), + ) return client_projection diff --git a/eve/io/media.py b/eve/io/media.py index 135db9d0d..f97f0b956 100644 --- a/eve/io/media.py +++ b/eve/io/media.py @@ -6,17 +6,17 @@ Media storage for Eve-powered APIs. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ -class MediaStorage(object): - """ The MediaStorage class provides a standardized API for storing files, +class MediaStorage(): + """The MediaStorage class provides a standardized API for storing files, along with a set of default behaviors that all other storage systems can inherit or override as necessary. - ..versioneadded:: 0.3 + ..versionadded:: 0.3 """ def __init__(self, app=None): @@ -28,14 +28,14 @@ def __init__(self, app=None): self.app = app def get(self, id_or_filename, resource=None): - """ Opens the file given by name or unique id. Note that although the + """Opens the file given by name or unique id. Note that although the returned file is guaranteed to be a File object, it might actually be some subclass. Returns None if no file was found. """ raise NotImplementedError def put(self, content, filename=None, content_type=None, resource=None): - """ Saves a new file using the storage system, preferably with the name + """Saves a new file using the storage system, preferably with the name specified. If there already exists a file with this name name, the storage system may modify the filename as necessary to get a unique name. Depending on the storage system, a unique id or the actual name @@ -48,14 +48,14 @@ def put(self, content, filename=None, content_type=None, resource=None): raise NotImplementedError def delete(self, id_or_filename, resource=None): - """ Deletes the file referenced by name or unique id. If deletion is + """Deletes the file referenced by name or unique id. If deletion is not supported on the target storage system this will raise NotImplementedError instead """ raise NotImplementedError def exists(self, id_or_filename, resource=None): - """ Returns True if a file referenced by the given name or unique id + """Returns True if a file referenced by the given name or unique id already exists in the storage system, or False if the name is available for a new file. """ diff --git a/eve/io/mongo/__init__.py b/eve/io/mongo/__init__.py index 5e22ddc11..f2616c72b 100644 --- a/eve/io/mongo/__init__.py +++ b/eve/io/mongo/__init__.py @@ -6,11 +6,11 @@ This package implements the MongoDB data layer. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ # flake8: noqa -from eve.io.mongo.mongo import Mongo, MongoJSONEncoder, create_index -from eve.io.mongo.validation import Validator +from eve.io.mongo.mongo import Mongo, MongoJSONEncoder, ensure_mongo_indexes from eve.io.mongo.media import GridFSMediaStorage +from eve.io.mongo.validation import Validator diff --git a/eve/io/mongo/flask_pymongo.py b/eve/io/mongo/flask_pymongo.py new file mode 100644 index 000000000..7bba0f2f1 --- /dev/null +++ b/eve/io/mongo/flask_pymongo.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- + +""" + eve.io.mongo.flask_pymongo + ~~~~~~~~~~~~~~~~~~~ + + Flask extension to create Mongo connection and database based on + configuration. + + :copyright: (c) 2017 by Nicola Iarocci. + :license: BSD, see LICENSE for more details. +""" +from bson import UuidRepresentation +from flask import current_app +from pymongo import MongoClient, uri_parser + + +class PyMongo(): + """ + Creates Mongo connection and database based on Flask configuration. + """ + + def __init__(self, app, config_prefix="MONGO"): + if "pymongo" not in app.extensions: + app.extensions["pymongo"] = {} + + if config_prefix in app.extensions["pymongo"]: + raise Exception('duplicate config_prefix "%s"' % config_prefix) + + self.config_prefix = config_prefix + + def key(suffix): + return "%s_%s" % (config_prefix, suffix) + + def config_to_kwargs(mapping): + """ + Convert config options to kwargs according to provided mapping + information. + """ + kwargs = {} + for option, arg in mapping.items(): + if key(option) in app.config: + kwargs[arg] = app.config[key(option)] + return kwargs + + app.config.setdefault(key("HOST"), "localhost") + app.config.setdefault(key("PORT"), 27017) + app.config.setdefault(key("DBNAME"), app.name) + app.config.setdefault(key("WRITE_CONCERN"), {"w": 1}) + client_kwargs = {"appname": app.name, "connect": True, "tz_aware": True} + if key("OPTIONS") in app.config: + client_kwargs.update(app.config[key("OPTIONS")]) + + if key("WRITE_CONCERN") in app.config: + # w, wtimeout, j and fsync + client_kwargs.update(app.config[key("WRITE_CONCERN")]) + + if key("REPLICA_SET") in app.config: + client_kwargs["replicaset"] = app.config[key("REPLICA_SET")] + + uri_parser.validate_options(client_kwargs) + + if key("URI") in app.config: + host = app.config[key("URI")] + # raises an exception if uri is invalid + mongo_settings = uri_parser.parse_uri(host) + + # extract username and password from uri + if mongo_settings.get("username"): + client_kwargs["username"] = mongo_settings["username"] + client_kwargs["password"] = mongo_settings["password"] + + # extract default database from uri + dbname = mongo_settings.get("database") + if not dbname: + dbname = app.config[key("DBNAME")] + + # extract auth source from uri + auth_source = mongo_settings["options"].get("authSource") + if not auth_source: + auth_source = dbname + else: + dbname = app.config[key("DBNAME")] + auth_source = dbname + host = app.config[key("HOST")] + client_kwargs["port"] = app.config[key("PORT")] + + client_kwargs["host"] = host + client_kwargs["authSource"] = auth_source + + if key("DOCUMENT_CLASS") in app.config: + client_kwargs["document_class"] = app.config[key("DOCUMENT_CLASS")] + + auth_kwargs = {} + if key("USERNAME") in app.config: + app.config.setdefault(key("PASSWORD"), None) + username = app.config[key("USERNAME")] + password = app.config[key("PASSWORD")] + auth = (username, password) + if any(auth) and not all(auth): + raise Exception("Must set both USERNAME and PASSWORD or neither") + client_kwargs["username"] = username + client_kwargs["password"] = password + if any(auth): + auth_mapping = { + "AUTH_MECHANISM": "authMechanism", + "AUTH_SOURCE": "authSource", + "AUTH_MECHANISM_PROPERTIES": "authMechanismProperties", + } + auth_kwargs = config_to_kwargs(auth_mapping) + + cx = MongoClient(**{**client_kwargs, **auth_kwargs}) + db = cx[dbname] + + app.extensions["pymongo"][config_prefix] = (cx, db) + + @property + def cx(self): + """ + Automatically created :class:`~pymongo.Connection` object corresponding + to the provided configuration parameters. + """ + if self.config_prefix not in current_app.extensions["pymongo"]: + raise Exception("flask_pymongo extensions is not initialized") + return current_app.extensions["pymongo"][self.config_prefix][0] + + @property + def db(self): + """ + Automatically created :class:`~pymongo.Database` object + corresponding to the provided configuration parameters. + """ + if self.config_prefix not in current_app.extensions["pymongo"]: + raise Exception("flask_pymongo extensions is not initialized") + return current_app.extensions["pymongo"][self.config_prefix][1] diff --git a/eve/io/mongo/geo.py b/eve/io/mongo/geo.py index 53bb1fa16..c2e8e16ed 100644 --- a/eve/io/mongo/geo.py +++ b/eve/io/mongo/geo.py @@ -6,61 +6,66 @@ Geospatial functions and classes for mongo IO layer - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ +from eve.utils import config class GeoJSON(dict): def __init__(self, json): try: - self['type'] = json['type'] + self["type"] = json["type"] except KeyError: - raise TypeError("Not compilant to GeoJSON") + raise TypeError("Not compliant to GeoJSON") self.update(json) - if len(self.keys()) != 2: - raise TypeError("Not compilant to GeoJSON") + if not config.ALLOW_CUSTOM_FIELDS_IN_GEOJSON and len(self.keys()) != 2: + raise TypeError("Not compliant to GeoJSON") def _correct_position(self, position): - return isinstance(position, list) and \ - all(isinstance(pos, int) or isinstance(pos, float) - for pos in position) + return ( + isinstance(position, list) + and len(position) > 1 + and all(isinstance(pos, (int, float)) for pos in position) + ) class Geometry(GeoJSON): def __init__(self, json): - super(Geometry, self).__init__(json) + super().__init__(json) try: - if not isinstance(self['coordinates'], list) or \ - self['type'] != self.__class__.__name__: + if ( + not isinstance(self["coordinates"], list) + or self["type"] != self.__class__.__name__ + ): raise TypeError except (KeyError, TypeError): - raise TypeError("Geometry not compilant to GeoJSON") + raise TypeError("Geometry not compliant to GeoJSON") class GeometryCollection(GeoJSON): def __init__(self, json): - super(GeometryCollection, self).__init__(json) + super().__init__(json) try: - if not isinstance(self['geometries'], list): + if not isinstance(self["geometries"], list): raise TypeError - for geometry in self['geometries']: + for geometry in self["geometries"]: factory = factories[geometry["type"]] factory(geometry) except (KeyError, TypeError, AttributeError): - raise TypeError("Geometry not compilant to GeoJSON") + raise TypeError("Geometry not compliant to GeoJSON") class Point(Geometry): def __init__(self, json): - super(Point, self).__init__(json) - if not self._correct_position(self['coordinates']): + super().__init__(json) + if not self._correct_position(self["coordinates"]): raise TypeError class MultiPoint(GeoJSON): def __init__(self, json): - super(MultiPoint, self).__init__(json) + super().__init__(json) for position in self["coordinates"]: if not self._correct_position(position): raise TypeError @@ -68,7 +73,7 @@ def __init__(self, json): class LineString(GeoJSON): def __init__(self, json): - super(LineString, self).__init__(json) + super().__init__(json) for position in self["coordinates"]: if not self._correct_position(position): raise TypeError @@ -76,7 +81,7 @@ def __init__(self, json): class MultiLineString(GeoJSON): def __init__(self, json): - super(MultiLineString, self).__init__(json) + super().__init__(json) for linestring in self["coordinates"]: for position in linestring: if not self._correct_position(position): @@ -85,7 +90,7 @@ def __init__(self, json): class Polygon(GeoJSON): def __init__(self, json): - super(Polygon, self).__init__(json) + super().__init__(json) for linestring in self["coordinates"]: for position in linestring: if not self._correct_position(position): @@ -94,7 +99,7 @@ def __init__(self, json): class MultiPolygon(GeoJSON): def __init__(self, json): - super(MultiPolygon, self).__init__(json) + super().__init__(json) for polygon in self["coordinates"]: for linestring in polygon: for position in linestring: @@ -102,7 +107,41 @@ def __init__(self, json): raise TypeError -factories = dict([(_type.__name__, _type) - for _type in - [GeometryCollection, Point, MultiPoint, LineString, - MultiLineString, Polygon, MultiPolygon]]) +class Feature(GeoJSON): + def __init__(self, json): + super().__init__(json) + try: + geometry = self["geometry"] + factory = factories[geometry["type"]] + factory(geometry) + + except (KeyError, TypeError, AttributeError): + raise TypeError("Feature not compliant to GeoJSON") + + +class FeatureCollection(GeoJSON): + def __init__(self, json): + super().__init__(json) + try: + if not isinstance(self["features"], list): + raise TypeError + for feature in self["features"]: + Feature(feature) + except (KeyError, TypeError, AttributeError): + raise TypeError("FeatureCollection not compliant to GeoJSON") + + +factories = dict( + [ + (_type.__name__, _type) + for _type in [ + GeometryCollection, + Point, + MultiPoint, + LineString, + MultiLineString, + Polygon, + MultiPolygon, + ] + ] +) diff --git a/eve/io/mongo/media.py b/eve/io/mongo/media.py index f4bf61208..e7237e4aa 100644 --- a/eve/io/mongo/media.py +++ b/eve/io/mongo/media.py @@ -4,7 +4,7 @@ GridFS media storage for Eve-powered APIs. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ from bson import ObjectId @@ -17,7 +17,7 @@ class GridFSMediaStorage(MediaStorage): - """ The GridFSMediaStorage class stores files into GridFS. + """The GridFSMediaStorage class stores files into GridFS. ..versionadded:: 0.3 """ @@ -31,23 +31,23 @@ def __init__(self, app=None): .. versionchanged:: 0.6 Support for multiple, cached, GridFS instances """ - super(GridFSMediaStorage, self).__init__(app) + super().__init__(app) self.validate() self._fs = {} def validate(self): - """ Make sure that the application data layer is a eve.io.mongo.Mongo + """Make sure that the application data layer is a eve.io.mongo.Mongo instance. """ if self.app is None: - raise TypeError('Application object cannot be None') + raise TypeError("Application object cannot be None") if not isinstance(self.app, Flask): - raise TypeError('Application object must be a Eve application') + raise TypeError("Application object must be a Eve application") def fs(self, resource=None): - """ Provides the instance-level GridFS instance, instantiating it if + """Provides the instance-level GridFS instance, instantiating it if needed. .. versionchanged:: 0.6 @@ -55,8 +55,7 @@ def fs(self, resource=None): """ driver = self.app.data if driver is None or not isinstance(driver, Mongo): - raise TypeError("Application data object must be of eve.io.Mongo " - "type.") + raise TypeError("Application data object must be of eve.io.Mongo " "type.") px = driver.current_mongo_prefix(resource) if px not in self._fs: @@ -64,10 +63,10 @@ def fs(self, resource=None): return self._fs[px] def get(self, _id, resource=None): - """ Returns the file given by unique id. Returns None if no file was + """Returns the file given by unique id. Returns None if no file was found. - .. vesionchanged: 0.6 + .. versionchanged: 0.6 Support for _id as string. """ if isinstance(_id, str_type): @@ -81,24 +80,24 @@ def get(self, _id, resource=None): _file = None try: _file = self.fs(resource).get(_id) - except: + except Exception: pass return _file def put(self, content, filename=None, content_type=None, resource=None): - """ Saves a new file in GridFS. Returns the unique id of the stored + """Saves a new file in GridFS. Returns the unique id of the stored file. Also stores content type of the file. """ - return self.fs(resource).put(content, filename=filename, - content_type=content_type) + return self.fs(resource).put( + content, filename=filename, content_type=content_type + ) def delete(self, _id, resource=None): - """ Deletes the file referenced by unique id. - """ + """Deletes the file referenced by unique id.""" self.fs(resource).delete(_id) def exists(self, id_or_document, resource=None): - """ Returns True if a file referenced by the unique id or the query + """Returns True if a file referenced by the unique id or the query document already exists, False otherwise. Valid query: {'filename': 'file.txt'} diff --git a/eve/io/mongo/mongo.py b/eve/io/mongo/mongo.py index 88f565f1b..ab44ff802 100644 --- a/eve/io/mongo/mongo.py +++ b/eve/io/mongo/mongo.py @@ -6,39 +6,47 @@ The actual implementation of the MongoDB data layer. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ +import ast +import decimal import itertools +from collections import OrderedDict +from copy import copy from datetime import datetime -import ast import pymongo import simplejson as json -from bson import ObjectId +from bson import ObjectId, decimal128 from bson.dbref import DBRef -from copy import copy -from flask import abort, request, g -from flask_pymongo import PyMongo +from flask import abort, g, request from pymongo import WriteConcern from werkzeug.exceptions import HTTPException from eve.auth import resource_auth -from eve.io.base import DataLayer, ConnectionException, BaseJSONEncoder -from eve.io.mongo.parser import parse, ParseError -from eve.utils import config, debug_error_message, validate_filters, \ - str_to_date, str_type +from eve.io.base import BaseJSONEncoder, ConnectionException, DataLayer +from eve.io.mongo.parser import ParseError, parse +from eve.utils import (config, debug_error_message, str_to_date, str_type, + validate_filters) + +from ...versioning import versioned_id_field +from .flask_pymongo import PyMongo class MongoJSONEncoder(BaseJSONEncoder): - """ Proprietary JSONEconder subclass used by the json render function. + """Proprietary JSONEconder subclass used by the json render function. This is needed to address the encoding of special values. + .. versionchanged:: 0.8.2 + Key-value pair order in DBRef are honored when encoding. Closes #1255. + .. versionchanged:: 0.6.2 Do not attempt to serialize callables. Closes #790. .. versionadded:: 0.2 """ + def default(self, obj): if isinstance(obj, ObjectId): # BSON/Mongo ObjectId is rendered as a string @@ -49,16 +57,20 @@ def default(self, obj): # (and we probably don't want it to be exposed anyway). See #790. return "" if isinstance(obj, DBRef): - retval = {'$id': str(obj.id), '$ref': obj.collection} + retval = OrderedDict() + retval["$ref"] = obj.collection + retval["$id"] = str(obj.id) if obj.database: - retval['$db'] = obj.database - return retval + retval["$db"] = obj.database + return json.RawJSON(json.dumps(retval)) + if isinstance(obj, decimal128.Decimal128): + return str(obj) # delegate rendering to base class method - return super(MongoJSONEncoder, self).default(obj) + return super().default(obj) class Mongo(DataLayer): - """ MongoDB data access layer for Eve REST API. + """MongoDB data access layer for Eve REST API. .. versionchanged:: 0.5 Properly serialize nullable float and integers. #469. @@ -75,14 +87,24 @@ class Mongo(DataLayer): """ serializers = { - 'objectid': lambda value: ObjectId(value) if value else None, - 'datetime': str_to_date, - 'integer': lambda value: int(value) if value is not None else None, - 'float': lambda value: float(value) if value is not None else None, - 'number': lambda val: json.loads(val) if val is not None else None, - 'dbref': lambda value: - DBRef(value['$col'], value['$id'], value['$db'] - if '$db' in value else None) if value is not None else None, + "objectid": lambda value: ObjectId(value) if value else None, + "datetime": str_to_date, + "integer": lambda value: int(value) if value is not None else None, + "float": lambda value: float(value) if value is not None else None, + "number": lambda val: json.loads(val) if val is not None else None, + "boolean": lambda v: {"1": True, "true": True, "0": False, "false": False}[ + str(v).lower() + ], + "dbref": lambda value: DBRef( + value["$col"] if "$col" in value else value["$ref"], + value["$id"], + value["$db"] if "$db" in value else None, + ) + if value is not None + else None, + "decimal": lambda value: decimal128.Decimal128(decimal.Decimal(str(value))) + if value is not None + else None, } # JSON serializer is a class attribute. Allows extensions to replace it @@ -90,17 +112,20 @@ class Mongo(DataLayer): json_encoder_class = MongoJSONEncoder operators = set( - ['$gt', '$gte', '$in', '$lt', '$lte', '$ne', '$nin'] + - ['$or', '$and', '$not', '$nor'] + - ['$mod', '$regex', '$text', '$where'] + - ['$options', '$search', '$language'] + - ['$exists', '$type'] + - ['$geoWithin', '$geoIntersects', '$near', '$nearSphere'] + - ['$all', '$elemMatch', '$size'] + ["$gt", "$gte", "$in", "$lt", "$lte", "$ne", "$nin", "$eq"] + + ["$or", "$and", "$not", "$nor"] + + ["$mod", "$regex", "$text", "$where"] + + ["$options", "$search", "$language", "$caseSensitive"] + + ["$diacriticSensitive", "$exists", "$type"] + + ["$geoWithin", "$geoIntersects", "$near", "$nearSphere", "$centerSphere"] + + ["$geometry", "$maxDistance", "$minDistance", "$box"] + + ["$all", "$elemMatch", "$size"] + + ["$bitsAllClear", "$bitsAllSet", "$bitsAnyClear", "$bitsAnySet"] + + ["$center", "$expr"] ) def init_app(self, app): - """ Initialize PyMongo. + """Initialize PyMongo. .. versionchanged:: 0.6 Use mongo_prefix for multidb support. @@ -112,8 +137,8 @@ def init_app(self, app): self.driver = PyMongos(self) self.mongo_prefix = None - def find(self, resource, req, sub_resource_lookup): - """ Retrieves a set of documents matching a given request. Queries can + def find(self, resource, req, sub_resource_lookup, perform_count=True): + """Retrieves a set of documents matching a given request. Queries can be expressed in two different formats: the mongo query syntax, and the python syntax. The first kind of query would look like: :: @@ -148,7 +173,7 @@ def find(self, resource, req, sub_resource_lookup): .. versionchanged:: 0.3 Support for new _mongotize() signature. - .. versionchagend:: 0.2 + .. versionchanged:: 0.2 Support for sub-resources. Support for 'default_sort'. @@ -174,13 +199,13 @@ def find(self, resource, req, sub_resource_lookup): .. versionchanged:: 0.0.4 retrieves the target collection via the new config.SOURCES helper. """ - args = dict() + args = {} - if req.max_results: - args['limit'] = req.max_results + if req and req.max_results: + args["limit"] = req.max_results - if req.page > 1: - args['skip'] = (req.page - 1) * req.max_results + if req and req.page > 1: + args["skip"] = (req.page - 1) * req.max_results # TODO sort syntax should probably be coherent with 'where': either # mongo-like # or python-like. Currently accepts only mongo-like sort @@ -189,42 +214,8 @@ def find(self, resource, req, sub_resource_lookup): # TODO should validate on unknown sort fields (mongo driver doesn't # return an error) - client_sort = {} - spec = {} - - if req.sort: - try: - # assume it's mongo syntax (ie. ?sort=[("name", 1)]) - client_sort = ast.literal_eval(req.sort) - except ValueError: - # it's not mongo so let's see if it's a comma delimited string - # instead (ie. "?sort=-age, name"). - sort = [] - for sort_arg in [s.strip() for s in req.sort.split(",")]: - if sort_arg[0] == "-": - sort.append((sort_arg[1:], -1)) - else: - sort.append((sort_arg, 1)) - if len(sort) > 0: - client_sort = sort - except Exception as e: - self.app.logger.exception(e) - abort(400, description=debug_error_message(str(e))) - - if req.where: - try: - spec = self._sanitize(json.loads(req.where)) - except HTTPException as e: - # _sanitize() is raising an HTTP exception; let it fire. - raise - except: - # couldn't parse as mongo query; give the python parser a shot. - try: - spec = parse(req.where) - except ParseError: - abort(400, description=debug_error_message( - 'Unable to parse `where` clause' - )) + client_sort = self._convert_sort_request_to_dict(req) + spec = self._convert_where_request_to_dict(resource, req) bad_filter = validate_filters(spec, resource) if bad_filter: @@ -233,44 +224,82 @@ def find(self, resource, req, sub_resource_lookup): if sub_resource_lookup: spec = self.combine_queries(spec, sub_resource_lookup) - if config.DOMAIN[resource]['soft_delete'] and not req.show_deleted: + if ( + config.DOMAIN[resource]["soft_delete"] + and not (req and req.show_deleted) + and not self.query_contains_field(spec, config.DELETED) + ): # Soft delete filtering applied after validate_filters call as # querying against the DELETED field must always be allowed when # soft_delete is enabled - if not self.query_contains_field(spec, config.DELETED): - spec = self.combine_queries( - spec, {config.DELETED: {"$ne": True}}) + spec = self.combine_queries(spec, {config.DELETED: {"$ne": True}}) spec = self._mongotize(spec, resource) client_projection = self._client_projection(req) datasource, spec, projection, sort = self._datasource_ex( - resource, - spec, - client_projection, - client_sort) + resource, spec, client_projection, client_sort + ) - if req.if_modified_since: - spec[config.LAST_UPDATED] = \ - {'$gt': req.if_modified_since} + if req and req.if_modified_since: + spec[config.LAST_UPDATED] = {"$gt": req.if_modified_since} if len(spec) > 0: - args['filter'] = spec + args["filter"] = spec if sort is not None: - args['sort'] = sort + args["sort"] = sort - if projection is not None: - args['projection'] = projection + if projection: + args["projection"] = projection - return self.pymongo(resource).db[datasource].find(**args) + target = self.pymongo(resource).db[datasource] + try: + result = target.find(**args) + except TypeError as e: + # pymongo raises ValueError when invalid query paramenters are + # included. We do our best to catch them beforehand but, especially + # with key/value sort syntax, invalid ones might still slip in. + self.app.logger.exception(e) + abort(400, description=debug_error_message(str(e))) - def find_one(self, resource, req, **lookup): - """ Retrieves a single document. + if perform_count: + try: + count = target.count_documents(spec) + except Exception: + # fallback to deprecated method. this might happen when the query + # includes operators not supported by count_documents(). one + # documented use-case is when we're running on mongo 3.4 and below, + # which does not support $expr ($expr must replace $where # in + # count_documents()). + + # 1. Mongo 3.6+; $expr: pass + # 2. Mongo 3.6+; $where: pass (via fallback) + # 3. Mongo 3.4; $where: pass (via fallback) + # 4. Mongo 3.4; $expr: fail (operator not supported by db) + + # See: http://api.mongodb.com/python/current/api/pymongo/collection.html#pymongo.collection.Collection.count + count = target.count() + else: + count = None + + return result, count + + def find_one( + self, + resource, + req, + check_auth_value=True, + force_auth_field_projection=False, + mongo_options=None, + **lookup + ): + """Retrieves a single document. :param resource: resource name. :param req: a :class:`ParsedRequest` instance. + :param mongo_options: Dict of parameters to pass to PyMongo with_options. :param **lookup: lookup query. .. versionchanged:: 0.6 @@ -301,39 +330,46 @@ def find_one(self, resource, req, **lookup): datasource, filter_, projection, _ = self._datasource_ex( resource, lookup, - client_projection) - - if (config.DOMAIN[resource]['soft_delete']) and \ - (not req or not req.show_deleted) and \ - (not self.query_contains_field(lookup, config.DELETED)): - filter_ = self.combine_queries( - filter_, {config.DELETED: {"$ne": True}}) + client_projection, + check_auth_value=check_auth_value, + force_auth_field_projection=force_auth_field_projection, + ) - document = self.pymongo(resource).db[datasource] \ - .find_one(filter_, projection) - return document + if ( + (config.DOMAIN[resource]["soft_delete"]) + and (not req or not req.show_deleted) + and (not self.query_contains_field(lookup, config.DELETED)) + ): + filter_ = self.combine_queries(filter_, {config.DELETED: {"$ne": True}}) + # Here, we feed pymongo with `None` if projection is empty. + target = self.pymongo(resource).db[datasource] + if mongo_options: + return target.with_options(**mongo_options).find_one( + filter_, projection or None + ) + return target.find_one(filter_, projection or None) - def find_one_raw(self, resource, _id): - """ Retrieves a single raw document. + def find_one_raw(self, resource, **lookup): + """Retrieves a single raw document. :param resource: resource name. - :param id: unique id. + :param **lookup: lookup query. .. versionchanged:: 0.6 Support for multiple databases. .. versionadded:: 0.4 """ - id_field = config.DOMAIN[resource]['id_field'] - datasource, filter_, _, _ = self._datasource_ex(resource, - {id_field: _id}, - None) + id_field = config.DOMAIN[resource]["id_field"] + _id = lookup.get(id_field) + datasource, filter_, _, _ = self._datasource_ex(resource, {id_field: _id}, None) - document = self.pymongo(resource).db[datasource].find_one(_id) - return document + lookup = self._mongotize(lookup, resource) + + return self.pymongo(resource).db[datasource].find_one(lookup) def find_list_of_ids(self, resource, ids, client_projection=None): - """ Retrieves a list of documents from the collection given + """Retrieves a list of documents from the collection given by `resource`, matching the given list of ids. This query is generated to *preserve the order* of the elements @@ -367,17 +403,19 @@ def find_list_of_ids(self, resource, ids, client_projection=None): .. versionadded:: 0.1.0 """ - id_field = config.DOMAIN[resource]['id_field'] - query = {'$or': [ - {id_field: id_} for id_ in ids - ]} + id_field = config.DOMAIN[resource]["id_field"] + query = {"$or": [{id_field: id_} for id_ in ids]} datasource, spec, projection, _ = self._datasource_ex( resource, query=query, client_projection=client_projection ) - - documents = self.pymongo(resource).db[datasource].find( - filter=spec, projection=projection + # projection of {} return all fields in MongoDB, but + # pymongo will only return `_id`. It's a design flaw upstream. + # Here, we feed pymongo with `None` if projection is empty. + documents = ( + self.pymongo(resource) + .db[datasource] + .find(filter=spec, projection=(projection or None)) ) return documents @@ -386,14 +424,12 @@ def aggregate(self, resource, pipeline, options): .. versionadded:: 0.7 """ datasource, _, _, _ = self.datasource(resource) - challenge = self._mongotize({'key': pipeline}, resource)['key'] + challenge = self._mongotize({"key": pipeline}, resource)["key"] - return self.pymongo(resource).db[datasource].aggregate( - challenge, **options - ) + return self.pymongo(resource).db[datasource].aggregate(challenge, **options) def insert(self, resource, doc_or_docs): - """ Inserts a document into a resource collection. + """Inserts a document into a resource collection. .. versionchanged:: 0.6.1 Support for PyMongo 3.0. @@ -435,21 +471,30 @@ def insert(self, resource, doc_or_docs): # report a duplicate key error since this can probably be # handled by the client. - for error in e.details['writeErrors']: + for error in e.details["writeErrors"]: # amazingly enough, pymongo does not appear to be exposing # error codes as constants. - if error['code'] == 11000: - abort(409, description=debug_error_message( - 'Duplicate key error at index: %s, message: %s' % ( - error['index'], error['errmsg']) - )) - - abort(500, description=debug_error_message( - 'pymongo.errors.BulkWriteError: %s' % e - )) + if error["code"] == 11000: + abort( + 409, + description=debug_error_message( + "Duplicate key error at index: %s, message: %s" + % (error["index"], error["errmsg"]) + ), + ) + + abort( + 500, + description=debug_error_message( + "pymongo.errors.BulkWriteError: %s" % e + ), + ) def _change_request(self, resource, id_, changes, original, replace=False): - """ Performs a change, be it a replace or update. + """Performs a change, be it a replace or update. + + .. versionchanged:: 0.8.2 + Return 400 if update/replace with malformed DBRef field. See #1257. .. versionchanged:: 0.6.1 Support for PyMongo 3.0. @@ -458,51 +503,65 @@ def _change_request(self, resource, id_, changes, original, replace=False): Return 400 if an attempt is made to update/replace an immutable field. """ - id_field = config.DOMAIN[resource]['id_field'] + id_field = config.DOMAIN[resource]["id_field"] query = {id_field: id_} if config.ETAG in original: query[config.ETAG] = original[config.ETAG] - datasource, filter_, _, _ = self._datasource_ex( - resource, query) + datasource, filter_, _, _ = self._datasource_ex(resource, query) coll = self.get_collection_with_write_concern(datasource, resource) try: - coll.replace_one(filter_, changes) if replace else \ - coll.update_one(filter_, changes) + result = ( + coll.replace_one(filter_, changes) + if replace + else coll.update_one(filter_, changes) + ) + if ( + config.ETAG in original + and result + and result.acknowledged + and result.modified_count == 0 + ): + raise self.OriginalChangedError() except pymongo.errors.DuplicateKeyError as e: - abort(400, description=debug_error_message( - 'pymongo.errors.DuplicateKeyError: %s' % e - )) - except pymongo.errors.OperationFailure as e: + abort( + 400, + description=debug_error_message( + "pymongo.errors.DuplicateKeyError: %s" % e + ), + ) + except (pymongo.errors.WriteError, pymongo.errors.OperationFailure) as e: # server error codes and messages changed between 2.4 and 2.6/3.0. - server_version = \ - self.driver.db.client.server_info()['version'][:3] - if ( - (server_version == '2.4' and e.code in (13596, 10148)) or - (server_version in ('2.6', '3.0', '3.2') and - e.code in (66, 16837)) + server_version = self.driver.db.client.server_info()["version"][:3] + if (server_version == "2.4" and e.code in (13596, 10148)) or e.code in ( + 66, + 16837, ): # attempt to update an immutable field. this usually # happens when a PATCH or PUT includes a mismatching ID_FIELD. - self.app.logger.warn(e) - description = debug_error_message( - 'pymongo.errors.OperationFailure: %s' % e) or \ - "Attempt to update an immutable field. Usually happens " \ - "when PATCH or PUT include a '%s' field, " \ - "which is immutable (PUT can include it as long as " \ + self.app.logger.warning(e) + description = ( + debug_error_message("pymongo.errors.OperationFailure: %s" % e) + or "Attempt to update an immutable field. Usually happens " + "when PATCH or PUT include a '%s' field, " + "which is immutable (PUT can include it as long as " "it is unchanged)." % id_field + ) abort(400, description=description) else: # see comment in :func:`insert()`. self.app.logger.exception(e) - abort(500, description=debug_error_message( - 'pymongo.errors.OperationFailure: %s' % e - )) + abort( + 500, + description=debug_error_message( + "pymongo.errors.OperationFailure: %s" % e + ), + ) def update(self, resource, id_, updates, original): - """ Updates a collection document. + """Updates a collection document. .. versionchanged:: 0.6 Support for multiple databases. @@ -536,7 +595,7 @@ def update(self, resource, id_, updates, original): return self._change_request(resource, id_, {"$set": updates}, original) def replace(self, resource, id_, document, original): - """ Replaces an existing document. + """Replaces an existing document. .. versionchanged:: 0.6 Support for multiple databases. @@ -548,17 +607,16 @@ def replace(self, resource, id_, document, original): Custom ID_FIELD lookups would fail. See #203. .. versionchanged:: 0.2 - Don't explicitly converto ID_FIELD to ObjectId anymore, so we can + Don't explicitly convert ID_FIELD to ObjectId anymore, so we can also process different types (UUIDs etc). .. versionadded:: 0.1.0 """ - return self._change_request(resource, id_, document, original, - replace=True) + return self._change_request(resource, id_, document, original, replace=True) def remove(self, resource, lookup): - """ Removes a document or the entire set of documents from a + """Removes a document or the entire set of documents from a collection. .. versionchanged:: 0.6.1 @@ -602,15 +660,18 @@ def remove(self, resource, lookup): except pymongo.errors.OperationFailure as e: # see comment in :func:`insert()`. self.app.logger.exception(e) - abort(500, description=debug_error_message( - 'pymongo.errors.OperationFailure: %s' % e - )) + abort( + 500, + description=debug_error_message( + "pymongo.errors.OperationFailure: %s" % e + ), + ) # TODO: The next three methods could be pulled out to form the basis # of a separate MonqoQuery class def combine_queries(self, query_a, query_b): - """ Takes two db queries and applies db-specific syntax to produce + """Takes two db queries and applies db-specific syntax to produce the intersection. This is used because we can't just dump one set of query operators @@ -641,14 +702,13 @@ def combine_queries(self, query_a, query_b): """ # Chain the operations with the $and operator return { - '$and': [ - {k: v} for k, v in itertools.chain(query_a.items(), - query_b.items()) + "$and": [ + {k: v} for k, v in itertools.chain(query_a.items(), query_b.items()) ] } def get_value_from_query(self, query, field_name): - """ For the specified field name, parses the query and returns + """For the specified field name, parses the query and returns the value being assigned in the query. For example, @@ -667,14 +727,14 @@ def get_value_from_query(self, query, field_name): """ if field_name in query: return query[field_name] - elif '$and' in query: - for condition in query['$and']: + if "$and" in query: + for condition in query["$and"]: if field_name in condition: return condition[field_name] raise KeyError def query_contains_field(self, query, field_name): - """ For the specified field name, does the query contain it? + """For the specified field name, does the query contain it? Used know whether we need to parse a compound query. .. versionadded: 0.1.0 @@ -687,10 +747,11 @@ def query_contains_field(self, query, field_name): return True def is_empty(self, resource): - """ Returns True if resource is empty; False otherwise. If there is no - predefined filter on the resource we're relying on the - db.collection.count(). However, if we do have a predefined filter we - have to fallback on the find() method, which can be much slower. + """Returns True if resource is empty; False otherwise. If there is + no predefined filter on the resource we're relying on the + db.collection.count_documents. However, if we do have a predefined + filter we have to fallback on the find() method, which can be much + slower. .. versionchanged:: 0.6 Support for multiple databases. @@ -703,25 +764,27 @@ def is_empty(self, resource): if not filter_: # faster, but we can only afford it if there's now predefined # filter on the datasource. - return coll.count() == 0 - else: - # fallback on find() since we have a filter to apply. - try: - # need to check if the whole resultset is missing, no - # matter the IMS header. - del filter_[config.LAST_UPDATED] - except: - pass - return coll.find(filter_).count() == 0 + return coll.count_documents({}) == 0 + # fallback on find() since we have a filter to apply. + try: + # need to check if the whole resultset is missing, no + # matter the IMS header. + del filter_[config.LAST_UPDATED] + except Exception: + pass + return coll.count_documents(filter_) == 0 except pymongo.errors.OperationFailure as e: # see comment in :func:`insert()`. self.app.logger.exception(e) - abort(500, description=debug_error_message( - 'pymongo.errors.OperationFailure: %s' % e - )) + abort( + 500, + description=debug_error_message( + "pymongo.errors.OperationFailure: %s" % e + ), + ) - def _mongotize(self, source, resource): - """ Recursively iterates a JSON dictionary, turning RFC-1123 strings + def _mongotize(self, source, resource, parse_objectid=False): + """Recursively iterates a JSON dictionary, turning RFC-1123 strings into datetime values and ObjectId-link strings into ObjectIds. .. versionchanged:: 0.3 @@ -740,48 +803,89 @@ def _mongotize(self, source, resource): .. versionadded:: 0.0.4 """ - schema = config.DOMAIN[resource] - skip_objectid = schema.get('query_objectid_as_string', False) - - def try_cast(v): + resource_def = config.DOMAIN[resource] + schema = resource_def.get("schema") + id_field = resource_def["id_field"] + id_field_versioned = versioned_id_field(resource_def) + query_objectid_as_string = resource_def.get("query_objectid_as_string", False) + parse_objectid = parse_objectid or not query_objectid_as_string + + def try_cast(k, v, should_parse_objectid): try: return datetime.strptime(v, config.DATE_FORMAT) - except: - if not skip_objectid: + except Exception: + if k in (id_field, id_field_versioned) or should_parse_objectid: try: # Convert to unicode because ObjectId() interprets # 12-character strings (but not unicode) as binary # representations of ObjectId's. See - # https://github.com/nicolaiarocci/eve/issues/508 + # https://github.com/pyeve/eve/issues/508 try: r = ObjectId(unicode(v)) except NameError: - # We're on Python 3 so it's all unicode # already. + # We're on Python 3 so it's all unicode already. r = ObjectId(v) return r - except: + except Exception: return v else: return v + def get_schema_type(keys, schema): + def dict_sub_schema(base): + if base.get("type") == "dict": + return base.get("schema") + return base + + if not isinstance(schema, dict): + return None + if not keys: + return schema.get("type") + + k = keys[0] + keys = keys[1:] + schema_type = schema[k].get("type") if k in schema else None + if schema_type == "list": + if "items" in schema[k]: + items = schema[k].get("items") or [] + possible_types = [get_schema_type(keys, item) for item in items] + if "objectid" in possible_types: + return "objectid" + return next((t for t in possible_types if t), None) + if "schema" in schema[k]: + # recursively check the schema + return get_schema_type(keys, dict_sub_schema(schema[k]["schema"])) + elif schema_type == "dict": + if "schema" in schema[k]: + return get_schema_type(keys, dict_sub_schema(schema[k]["schema"])) + else: + return schema_type + for k, v in source.items(): + keys = k.split(".") + schema_type = get_schema_type(keys, schema) + is_objectid = (schema_type == "objectid") or parse_objectid if isinstance(v, dict): - self._mongotize(v, resource) + self._mongotize(v, resource, is_objectid) elif isinstance(v, list): for i, v1 in enumerate(v): if isinstance(v1, dict): source[k][i] = self._mongotize(v1, resource) else: - source[k][i] = try_cast(v1) + source[k][i] = try_cast(k, v1, is_objectid) elif isinstance(v, str_type): - source[k] = try_cast(v) + source[k] = try_cast(k, v, is_objectid) return source - def _sanitize(self, spec): - """ Makes sure that only allowed operators are included in the query, + def _sanitize(self, resource, spec): + """Makes sure that only allowed operators are included in the query, aborts with a 400 otherwise. + .. versionchanged:: 1.1.0 + Add mongo_query_whitelist config option to extend the list of + supported operators + .. versionchanged:: 0.5 Abort with 400 if unsupported query operators are used. #387. DRY. @@ -792,35 +896,99 @@ def _sanitize(self, spec): .. versionadded:: 0.0.7 """ + def sanitize_keys(spec): - ops = set([op for op in spec.keys() if op[0] == '$']) - unknown = ops - Mongo.operators + ops = set([op for op in spec.keys() if op[0] == "$"]) + known = Mongo.operators | set( + config.DOMAIN[resource]["mongo_query_whitelist"] + ) + + unknown = ops - known if unknown: - abort(400, description=debug_error_message( - 'Query contains unknown or unsupported operators: %s' % - ', '.join(unknown) - )) + abort( + 400, + description=debug_error_message( + "Query contains unknown or unsupported operators: %s" + % ", ".join(unknown) + ), + ) if set(spec.keys()) & set(config.MONGO_QUERY_BLACKLIST): - abort(400, description=debug_error_message( - 'Query contains operators banned in MONGO_QUERY_BLACKLIST' - )) - - sanitize_keys(spec) - for value in spec.values(): - if isinstance(value, dict): - sanitize_keys(value) + abort( + 400, + description=debug_error_message( + "Query contains operators banned in MONGO_QUERY_BLACKLIST" + ), + ) + + if isinstance(spec, dict): + sanitize_keys(spec) + for value in spec.values(): + self._sanitize(resource, value) + if isinstance(spec, list): + for value in spec: + self._sanitize(resource, value) + return spec + def _convert_sort_request_to_dict(self, req): + """Converts the contents of a `ParsedRequest`'s `sort` property to + a dict + """ + client_sort = {} + if req and req.sort: + try: + # assume it's mongo syntax (ie. ?sort=[("name", 1)]) + client_sort = ast.literal_eval(req.sort) + except ValueError: + # it's not mongo so let's see if it's a comma delimited string + # instead (ie. "?sort=-age, name"). + sort = [] + for sort_arg in [s.strip() for s in req.sort.split(",")]: + if sort_arg[0] == "-": + sort.append((sort_arg[1:], -1)) + else: + sort.append((sort_arg, 1)) + if len(sort) > 0: + client_sort = sort + except Exception as e: + self.app.logger.exception(e) + abort(400, description=debug_error_message(str(e))) + return client_sort + + def _convert_where_request_to_dict(self, resource, req): + """Converts the contents of a `ParsedRequest`'s `where` property to + a dict + """ + query = {} + if req and req.where: + try: + query = self._sanitize(resource, json.loads(req.where)) + except HTTPException: + # _sanitize() is raising an HTTP exception; let it fire. + raise + except Exception: + # couldn't parse as mongo query; give the python parser a shot. + try: + query = parse(req.where) + except ParseError: + abort( + 400, + description=debug_error_message( + "Unable to parse `where` clause" + ), + ) + return query + def _wc(self, resource): - """ Syntactic sugar for the current collection write_concern setting. + """Syntactic sugar for the current collection write_concern setting. .. versionadded:: 0.0.8 """ - return config.DOMAIN[resource]['mongo_write_concern'] + return config.DOMAIN[resource]["mongo_write_concern"] def current_mongo_prefix(self, resource=None): - """ Returns the active mongo_prefix that should be used to retrieve + """Returns the active mongo_prefix that should be used to retrieve a valid PyMongo instance from the cache. If 'self.mongo_prefix' is set it has precedence over both endpoint (resource) and default drivers. This allows Auth classes (for instance) to override default settings to @@ -849,7 +1017,7 @@ def current_mongo_prefix(self, resource=None): auth = None try: if resource is None and request and request.endpoint: - resource = request.endpoint[:request.endpoint.index('|')] + resource = request.endpoint[: request.endpoint.index("|")] if request and request.endpoint: auth = resource_auth(resource) except ValueError: @@ -858,18 +1026,18 @@ def current_mongo_prefix(self, resource=None): px = auth.get_mongo_prefix() if auth else None if px is None: - px = g.get('mongo_prefix', None) + px = g.get("mongo_prefix", None) if px is None: if resource: - px = config.DOMAIN[resource].get('mongo_prefix', 'MONGO') + px = config.DOMAIN[resource].get("mongo_prefix", "MONGO") else: - px = 'MONGO' + px = "MONGO" return px def pymongo(self, resource=None, prefix=None): - """ Returns an active PyMongo instance. If 'prefix' is defined then + """Returns an active PyMongo instance. If 'prefix' is defined then it has precedence over the endpoint ('resource') and/or 'self.mongo_instance'. @@ -894,7 +1062,7 @@ def pymongo(self, resource=None, prefix=None): raise ConnectionException(e) def get_collection_with_write_concern(self, datasource, resource): - """ Returns a pymongo Collection with the desired write_concern + """Returns a pymongo Collection with the desired write_concern setting. PyMongo 3.0+ collections are immutable, yet we still want to allow the @@ -903,24 +1071,24 @@ def get_collection_with_write_concern(self, datasource, resource): .. versionadded:: 0.6.1 """ - wc = WriteConcern(config.DOMAIN[resource]['mongo_write_concern']['w']) - return self.pymongo(resource).db[datasource].with_options( - write_concern=wc) + wc = WriteConcern(config.DOMAIN[resource]["mongo_write_concern"]["w"]) + return self.pymongo(resource).db[datasource].with_options(write_concern=wc) class PyMongos(dict): - """ Cache for PyMongo instances. It is just a normal dict which exposes + """Cache for PyMongo instances. It is just a normal dict which exposes a 'db' property for backward compatibility. .. versionadded:: 0.6 """ + def __init__(self, mongo, *args): self.mongo = mongo dict.__init__(self, args) @property def db(self): - """ Returns the 'default' PyMongo instance, which is either the + """Returns the 'default' PyMongo instance, which is either the 'Mongo.mongo_prefix' value or 'MONGO'. This property is useful for backward compatibility as many custom Auth classes use the now obsolete 'self.data.driver.db[collection]' pattern. @@ -928,8 +1096,28 @@ def db(self): return self.mongo.pymongo().db -def create_index(app, resource, name, list_of_keys, index_options): - """ Create a specific index composed of the `list_of_keys` for the +def ensure_mongo_indexes(app, resource): + """Make sure 'mongo_indexes' is respected and mongo indexes are created on + the current database. + + .. versionaddded:: 0.8 + """ + mongo_indexes = app.config["DOMAIN"][resource]["mongo_indexes"] + if not mongo_indexes: + return + + for name, value in mongo_indexes.items(): + if isinstance(value, tuple): + list_of_keys, index_options = value + else: + list_of_keys = value + index_options = {} + + _create_index(app, resource, name, list_of_keys, index_options) + + +def _create_index(app, resource, name, list_of_keys, index_options): + """Create a specific index composed of the `list_of_keys` for the mongo collection behind the `resource` using the `app.config` to retrieve all data needed to find out the mongodb configuration. The index is also configured by the `index_options`. @@ -949,61 +1137,41 @@ def create_index(app, resource, name, list_of_keys, index_options): For example: {"sparse": True} + .. versionchanged:: 0.8.1 + Add support for IndexKeySpecsConflict error. See #1180. + .. versionadded:: 0.6 + """ # it doesn't work as a typical mongodb method run in the request - # life cicle, it is just called when the app start and it uses + # life cycle, it is just called when the app start and it uses # pymongo directly. - collection = app.config['SOURCES'][resource]['source'] - - if 'MONGO_URI' in app.config and app.config['MONGO_URI']: - mongo_options = app.config.get('MONGO_OPTIONS', {}) - conn = pymongo.MongoClient(app.config['MONGO_URI'], **mongo_options) - db = conn.get_default_database() - else: - config_prefix = app.config['DOMAIN'][resource].get('mongo_prefix', - 'MONGO') - - def key(suffix): - return '%s_%s' % (config_prefix, suffix) - - db_name = app.config[key('DBNAME')] - - # just reproduced the same behaviour for username - # and password, the other fields come set by Eve by - # default. - username = app.config[key('USERNAME')] \ - if key('USERNAME') in app.config else None - password = app.config[key('PASSWORD')] \ - if key('PASSWORD') in app.config else None - auth_db_name = app.config[key('AUTHDBNAME')] \ - if key('AUTHDBNAME') in app.config else None - host = app.config[key('HOST')] - port = app.config[key('PORT')] - auth = (username, password) - host_and_port = '%s:%s' % (host, port) - mongo_options = app.config.get(key('OPTIONS'), {}) - conn = pymongo.MongoClient(host_and_port, **mongo_options) - db = conn[db_name] - - if any(auth): - db.authenticate(username, password, source=auth_db_name) + collection = app.config["SOURCES"][resource]["source"] + + # get db for given prefix + try: + # mongo_prefix might have been set by Auth class instance + px = g.get("mongo_prefix") + except Exception: + px = app.config["DOMAIN"][resource].get("mongo_prefix", "MONGO") + + with app.app_context(): + db = app.data.pymongo(resource, px).db kw = copy(index_options) - kw['name'] = name + kw["name"] = name colls = [db[collection]] - if app.config['DOMAIN'][resource]['versioning']: - colls.append(db['%s_versions' % collection]) + if app.config["DOMAIN"][resource]["versioning"]: + colls.append(db["%s_versions" % collection]) for coll in colls: try: coll.create_index(list_of_keys, **kw) except pymongo.errors.OperationFailure as e: - if e.code == 85: - # This error is raised when the definition of the index has - # been changed, we didn't found any spec out there but we think - # that this error is not going to change and we can trust. + if e.code in (85, 86): + # raised when the definition of the index has been changed. + # (https://github.com/mongodb/mongo/blob/master/src/mongo/base/error_codes.err#L87) # by default, drop the old index with old configuration and # create the index again with the new configuration. diff --git a/eve/io/mongo/parser.py b/eve/io/mongo/parser.py index 7e357a583..fad0d82f3 100644 --- a/eve/io/mongo/parser.py +++ b/eve/io/mongo/parser.py @@ -5,20 +5,21 @@ ~~~~~~~~~~~~~~~~~~~ This module implements a Python-to-Mongo syntax parser. Allows the MongoDB - data-layer to seamlessy respond to a Python-like query. + data-layer to seamlessly respond to a Python-like query. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ import ast import sys -from datetime import datetime # noqa -from bson import ObjectId # noqa +from datetime import datetime # noqa + +from bson import ObjectId # noqa def parse(expression): - """ Given a python-like conditional statement, returns the equivalent + """Given a python-like conditional statement, returns the equivalent mongo-like query expression. Conditional and boolean operators (==, <=, >=, !=, >, <) along with a couple function calls (ObjectId(), datetime()) are supported. @@ -38,27 +39,27 @@ class ParseError(ValueError): class MongoVisitor(ast.NodeVisitor): - """ Implements the python-to-mongo parser. Only Python conditional + """Implements the python-to-mongo parser. Only Python conditional statements are supported, however nested, combined with most common compare and boolean operators (And and Or). Supported compare operators: ==, >, <, !=, >=, <= Supported boolean operators: And, Or """ + op_mapper = { - ast.Eq: '', - ast.Gt: '$gt', - ast.GtE: '$gte', - ast.Lt: '$lt', - ast.LtE: '$lte', - ast.NotEq: '$ne', - ast.Or: '$or', - ast.And: '$and' + ast.Eq: "", + ast.Gt: "$gt", + ast.GtE: "$gte", + ast.Lt: "$lt", + ast.LtE: "$lte", + ast.NotEq: "$ne", + ast.Or: "$or", + ast.And: "$and", } def visit_Module(self, node): - """ Module handler, our entry point. - """ + """Module handler, our entry point.""" self.mongo_query = {} self.ops = [] self.current_value = None @@ -66,24 +67,25 @@ def visit_Module(self, node): # perform the magic. self.generic_visit(node) - # if we didn't obtain a query, it is likely that an unsopported + # if we didn't obtain a query, it is likely that an unsupported # python expression has been passed. - if self.mongo_query == {}: - raise ParseError("Only conditional statements with boolean " - "(and, or) and comparison operators are " - "supported.") + if not self.mongo_query: + raise ParseError( + "Only conditional statements with boolean " + "(and, or) and comparison operators are " + "supported." + ) def visit_Expr(self, node): - """ Make sure that we are parsing compare or boolean operators - """ - if not (isinstance(node.value, ast.Compare) or - isinstance(node.value, ast.BoolOp)): + """Make sure that we are parsing compare or boolean operators""" + if not ( + isinstance(node.value, ast.Compare) or isinstance(node.value, ast.BoolOp) + ): raise ParseError("Will only parse conditional statements") self.generic_visit(node) def visit_Compare(self, node): - """ Compare operator handler. - """ + """Compare operator handler.""" self.visit(node.left) left = self.current_value @@ -93,7 +95,7 @@ def visit_Compare(self, node): comparator = node.comparators[0] self.visit(comparator) - if operator != '': + if operator != "": value = {operator: self.current_value} else: value = self.current_value @@ -104,8 +106,7 @@ def visit_Compare(self, node): self.mongo_query[left] = value def visit_BoolOp(self, node): - """ Boolean operator handler. - """ + """Boolean operator handler.""" op = self.op_mapper[node.op.__class__] self.ops.append([]) for value in node.values: @@ -118,38 +119,37 @@ def visit_BoolOp(self, node): self.mongo_query[op] = c def visit_Call(self, node): - """ A couple function calls are supported: bson's ObjectId() and + """A couple function calls are supported: bson's ObjectId() and datetime(). """ if isinstance(node.func, ast.Name): - expr = None - if node.func.id == 'ObjectId': - expr = "('" + node.args[0].s + "')" - elif node.func.id == 'datetime': + if node.func.id == "ObjectId": + try: + self.current_value = ObjectId(node.args[0].s) + except Exception: + pass + elif node.func.id == "datetime": values = [] for arg in node.args: - values.append(str(arg.n)) - expr = "(" + ", ".join(values) + ")" - if expr: - self.current_value = eval(node.func.id + expr) + values.append(arg.n) + try: + self.current_value = datetime(*values) + except Exception: + pass def visit_Attribute(self, node): - """ Attribute handler ('Contact.Id'). - """ + """Attribute handler ('Contact.Id').""" self.visit(node.value) self.current_value += "." + node.attr def visit_Name(self, node): - """ Names handler. - """ + """Names handler.""" self.current_value = node.id def visit_Num(self, node): - """ Numbers handler. - """ + """Numbers handler.""" self.current_value = node.n def visit_Str(self, node): - """ Strings handler. - """ + """Strings handler.""" self.current_value = node.s diff --git a/eve/io/mongo/validation.py b/eve/io/mongo/validation.py index a51f756e4..4f8ebe36e 100644 --- a/eve/io/mongo/validation.py +++ b/eve/io/mongo/validation.py @@ -8,26 +8,25 @@ objects incoming via POST/PATCH requests conform to the API domain. An extension of Cerberus Validator. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ -import copy -from bson import ObjectId +from bson import ObjectId, decimal128 from bson.dbref import DBRef -from collections import Mapping from flask import current_app as app from werkzeug.datastructures import FileStorage -from cerberus import Validator from eve.auth import auth_field_and_value -from eve.io.mongo.geo import Point, MultiPoint, LineString, Polygon, \ - MultiLineString, MultiPolygon, GeometryCollection -from eve.utils import config, str_type +from eve.io.mongo.geo import (Feature, FeatureCollection, GeometryCollection, + LineString, MultiLineString, MultiPoint, + MultiPolygon, Point, Polygon) +from eve.utils import config +from eve.validation import Validator from eve.versioning import get_data_version_relation_document class Validator(Validator): - """ A cerberus.Validator subclass adding the `unique` contraint to + """A cerberus.Validator subclass adding the `unique` contraint to Cerberus standard validation. :param schema: the validation schema, to be composed according to Cerberus @@ -52,69 +51,13 @@ class Validator(Validator): Support for 'transparent_schema_rules' introduced with Cerberus 0.0.3, which allows for insertion of 'default' values in POST requests. """ - def __init__(self, schema=None, resource=None, allow_unknown=False, - transparent_schema_rules=False): - self.resource = resource - self._id = None - self._original_document = None - - if resource: - transparent_schema_rules = \ - config.DOMAIN[resource]['transparent_schema_rules'] - allow_unknown = config.DOMAIN[resource]['allow_unknown'] - super(Validator, self).__init__( - schema, - transparent_schema_rules=transparent_schema_rules, - allow_unknown=allow_unknown) - - def validate_update(self, document, _id, original_document=None): - """ Validate method to be invoked when performing an update, not an - insert. - - :param document: the document to be validated. - :param _id: the unique id of the document. - """ - self._id = _id - self._original_document = original_document - return super(Validator, self).validate_update(document) - - def validate_replace(self, document, _id, original_document=None): - """ Validation method to be invoked when performing a document - replacement. This differs from :func:`validation_update` since in this - case we want to perform a full :func:`validate` (the new document is to - be considered a new insertion and required fields needs validation). - However, like with validate_update, we also want the current _id - not to be checked when validationg 'unique' values. - - .. versionadded:: 0.1.0 - """ - self._id = _id - self._original_document = original_document - return super(Validator, self).validate(document) - - def _validate_default(self, unique, field, value): - """ Fake validate function to let cerberus accept "default" - as keyword in the schema - - .. versionadded:: 0.6.2 - """ - pass def _validate_versioned(self, unique, field, value): - """ Fake validate function to let cerberus accept "versioned" - as keyword in the schema - - .. versionadded:: 0.6.2 - """ + """{'type': 'boolean'}""" pass def _validate_unique_to_user(self, unique, field, value): - """ Validates that a value is unique to the active user. Active user is - the user authenticated for current request. See #646. - - .. versionadded: 0.6 - """ - + """{'type': 'boolean'}""" auth_field, auth_value = auth_field_and_value(self.resource) # if an auth value has been set for this request, then make sure it is @@ -123,29 +66,19 @@ def _validate_unique_to_user(self, unique, field, value): self._is_value_unique(unique, field, value, query) - def _validate_unique(self, unique, field, value): - """ Enables validation for `unique` schema attribute. - - :param unique: Boolean, wether the field value should be - unique or not. - :param field: field name. - :param value: field value. - - .. versionchanged:: 0.6 - Validates field value uniqueness against the whole datasource, - indipendently of the request method. See #646. - - .. versionchanged:: 0.3 - Support for new 'self._error' signature introduced with Cerberus - v0.5. + def _validate_unique_within_resource(self, unique, field, value): + """{'type': 'boolean'}""" + _, filter_, _, _ = app.data.datasource(self.resource) + if filter_ is None: + filter_ = {} + self._is_value_unique(unique, field, value, filter_) - .. versionchanged:: 0.2 - Handle the case in which ID_FIELD is not of ObjectId type. - """ + def _validate_unique(self, unique, field, value): + """{'type': 'boolean'}""" self._is_value_unique(unique, field, value, {}) def _is_value_unique(self, unique, field, value, query): - """ Validates that a field value is unique. + """Validates that a field value is unique. .. versionchanged:: 0.6.2 Exclude soft deleted documents from uniqueness check. Closes #831. @@ -153,12 +86,31 @@ def _is_value_unique(self, unique, field, value, query): .. versionadded:: 0.6 """ if unique: - query[field] = value + # In order to create the right query to check for unique values + # We need to obtain the schema path for the current field + # excluding any list fields in between. + schema = self.root_schema + document_field_path = list(self.document_path) + [field] + field_schema_path = [] + + while document_field_path: + current_schema_path_type = schema.get("type") + path = document_field_path.pop(0) + if current_schema_path_type == "dict": + schema = schema["schema"][path] + field_schema_path.append(path) + elif schema.get("type") == "list": + schema = schema["schema"] + else: + schema = schema[path] + field_schema_path.append(path) + + query[".".join(field_schema_path)] = value resource_config = config.DOMAIN[self.resource] # exclude soft deleted documents if applicable - if resource_config['soft_delete']: + if resource_config["soft_delete"]: # be aware that, should a previously (soft) deleted document be # restored, and because we explicitly ignore soft deleted # documents while validating 'unique' fields, there is a chance @@ -170,12 +122,15 @@ def _is_value_unique(self, unique, field, value, query): # we make sure to also include documents which are missing the # DELETED field. This happens when soft deletes are enabled on # an a resource with existing documents. - query[config.DELETED] = {'$ne': True} + query[config.DELETED] = {"$ne": True} # exclude current document - if self._id: - id_field = resource_config['id_field'] - query[id_field] = {'$ne': self._id} + if self.document_id: + id_field = resource_config["id_field"] + if id_field in query: + query[id_field] = {"$ne": self.document_id, "$eq": query[id_field]} + else: + query[id_field] = {"$ne": self.document_id} # we perform the check on the native mongo driver (and not on # app.data.find_one()) because in this case we don't want the usual @@ -187,273 +142,159 @@ def _is_value_unique(self, unique, field, value, query): self._error(field, "value '%s' is not unique" % value) def _validate_data_relation(self, data_relation, field, value): - """ Enables validation for `data_relation` field attribute. Makes sure - 'value' of 'field' adheres to the referential integrity rule specified - by 'data_relation'. - - :param data_relation: a dict following keys: - 'resource': foreign resource name - 'field': foreign field name - 'version': True if this relation points to a specific version - 'type': the type for the reference field if 'version': True - :param field: field name. - :param value: field value. - - .. versionchanged:: 0.4 - Support for document versioning. - - .. versionchanged:: 0.3 - Support for new 'self._error' signature introduced with Cerberus - v0.5. - - .. versionchanged:: 0.1.1 - 'collection' key renamed to 'resource' (data_relation) - - .. versionadded: 0.0.5 - """ - if 'version' in data_relation and data_relation['version'] is True: - value_field = data_relation['field'] - version_field = app.config['VERSION'] + """{'type': 'dict', + 'schema': { + 'resource': {'type': 'string', 'required': True}, + 'field': {'type': 'string', 'required': True}, + 'embeddable': {'type': 'boolean', 'default': False}, + 'version': {'type': 'boolean', 'default': False} + }}""" + if not value and self.schema[field].get("nullable"): + return + + if "version" in data_relation and data_relation["version"] is True: + value_field = data_relation["field"] + version_field = app.config["VERSION"] # check value format - if isinstance(value, dict) and value_field in value \ - and version_field in value: - resource_def = config.DOMAIN[data_relation['resource']] - if resource_def['versioning'] is False: + if ( + isinstance(value, dict) + and value_field in value + and version_field in value + ): + resource_def = config.DOMAIN[data_relation["resource"]] + if resource_def["versioning"] is False: self._error( - field, "can't save a version with" - " data_relation if '%s' isn't versioned" % - data_relation['resource']) + field, + "can't save a version with" + " data_relation if '%s' isn't versioned" + % data_relation["resource"], + ) else: - search = get_data_version_relation_document( - data_relation, value) + search = get_data_version_relation_document(data_relation, value) if not search: self._error( - field, "value '%s' must exist in resource" - " '%s', field '%s' at version '%s'." % ( - value[value_field], data_relation['resource'], - data_relation['field'], value[version_field])) + field, + "value '%s' must exist in resource" + " '%s', field '%s' at version '%s'." + % ( + value[value_field], + data_relation["resource"], + data_relation["field"], + value[version_field], + ), + ) else: self._error( - field, "versioned data_relation must be a dict" - " with fields '%s' and '%s'" % - (value_field, version_field)) + field, + "versioned data_relation must be a dict" + " with fields '%s' and '%s'" % (value_field, version_field), + ) else: if not isinstance(value, list): value = [value] - data_resource = data_relation['resource'] + data_resource = data_relation["resource"] for item in value: - query = {data_relation['field']: item.id - if isinstance(item, DBRef) else item} - if not app.data.find_one(data_resource, None, **query): - self._error( - field, - "value '%s' must exist in resource" - " '%s', field '%s'." % - (item.id if isinstance(item, DBRef) else item, - data_resource, data_relation['field'])) - - def _validate_type_objectid(self, field, value): - """ Enables validation for `objectid` data type. - - :param field: field name. - :param value: field value. - - .. versionchanged:: 0.3 - Support for new 'self._error' signature introduced with Cerberus - v0.5. - - .. versionchanged:: 0.1.1 - regex check replaced with proper type check. - """ - if not isinstance(value, ObjectId): - self._error(field, "value '%s' cannot be converted to a ObjectId" - % value) - - def _validate_type_dbref(self, field, value): - """ Enables validation for `DBRef` data type. - - :param field: field name. - :param value: field value. - - """ - if not isinstance(value, DBRef): - self._error(field, "value '%s' cannot be converted to a DBRef" - % value) - - def _validate_readonly(self, read_only, field, value): - """ - .. versionchanged:: 0.5 - Not taking defaul values in consideration anymore since they are now - being resolved after validation (#353). - Consider the original value if available (#479). + query = { + data_relation["field"]: item.id if isinstance(item, DBRef) else item + } + if not app.data.find_one(data_resource, None, **query): + self._error( + field, + "value '%s' must exist in resource" + " '%s', field '%s'." + % ( + item.id if isinstance(item, DBRef) else item, + data_resource, + data_relation["field"], + ), + ) + + def _validate_type_objectid(self, value): + if ObjectId.is_valid(value): + return True - .. versionadded:: 0.4 - """ - original_value = self._original_document.get(field) \ - if self._original_document else None - if value != original_value: - super(Validator, self)._validate_readonly(read_only, field, value) - - def _validate_dependencies(self, document, dependencies, field, - break_on_error=False): - """ With PATCH method, the validator is only provided with the updated - fields. If an updated field depends on another field in order to be - edited and the other field was previously set, the validator doesn't - see it and rejects the update. In order to avoid that we merge the - proposed changes with the original document before validating - dependencies. - - .. versionchanged:: 0.6.1 - Fix: dependencies on sub-document fields are now properly - processed (#706). - - .. versionchanged:: 0.6 - Fix: Only evaluate dependencies that don't have valid default - values. - - .. versionchanged:: 0.5.1 - Fix: dependencies with value checking seems broken #547. - - .. versionadded:: 0.5 - If a dependency has a default value, skip it as Cerberus does not - have the notion of default values and would report a missing - dependency (#353). - Fix for #363 (see docstring). - """ - if dependencies is None: + def _validate_type_decimal(self, value): + if isinstance(value, decimal128.Decimal128): return True - if isinstance(dependencies, str_type): - dependencies = [dependencies] - - defaults = {} - for d in dependencies: - root = d.split('.')[0] - default = self.schema[root].get('default') - if default and root not in document: - defaults[root] = default - - if isinstance(dependencies, Mapping): - # Only evaluate dependencies that don't have *valid* defaults - for k, v in defaults.items(): - if v in dependencies[k]: - del(dependencies[k]) - else: - # Only evaluate dependencies that don't have defaults values - dependencies = [d for d in dependencies if d not in - defaults.keys()] - - dcopy = None - if self._original_document: - dcopy = copy.copy(document) - dcopy.update(self._original_document) - return super(Validator, self)._validate_dependencies(dcopy or document, - dependencies, - field, - break_on_error) - - def _validate_type_media(self, field, value): - """ Enables validation for `media` data type. - - :param field: field name. - :param value: field value. - - .. versionadded:: 0.3 - """ - if not isinstance(value, FileStorage): - self._error(field, "file was expected, got '%s' instead." % value) + def _validate_type_dbref(self, value): + if isinstance(value, DBRef): + return True - def _validate_type_point(self, field, value): - """ Enables validation for `point` data type. + def _validate_type_media(self, value): + if isinstance(value, FileStorage): + return True - :param field: field name. - :param value: field value. - """ + def _validate_type_point(self, value): try: Point(value) - except TypeError as e: - self._error(field, "Point not correct %s: %s" % (value, e)) - - def _validate_type_linestring(self, field, value): - """ Enables validation for `linestring` data type. + return True + except TypeError: + pass - :param field: field name. - :param value: field value. - """ + def _validate_type_linestring(self, value): try: LineString(value) + return True except TypeError: - self._error(field, "LineString not correct %s " % value) - - def _validate_type_polygon(self, field, value): - """ Enables validation for `polygon` data type. + pass - :param field: field name. - :param value: field value. - """ + def _validate_type_polygon(self, value): try: Polygon(value) + return True except TypeError: - self._error(field, "LineString not correct %s " % value) - - def _validate_type_multipoint(self, field, value): - """ Enables validation for `multipoint` data type. + pass - :param field: field name. - :param value: field value. - """ + def _validate_type_multipoint(self, value): try: MultiPoint(value) + return True except TypeError: - self._error(field, "MultiPoint not correct" % value) - - def _validate_type_multilinestring(self, field, value): - """ Enables validation for `multilinestring`data type. + pass - :param field: field name. - :param value: field value. - """ + def _validate_type_multilinestring(self, value): try: MultiLineString(value) + return True except TypeError: - self._error(field, "MultiLineString not correct" % value) + pass - def _validate_type_multipolygon(self, field, value): - """ Enables validation for `multipolygon` data type. - - :param field: field name. - :param value: field value. - """ + def _validate_type_multipolygon(self, value): try: MultiPolygon(value) + return True except TypeError: - self._error(field, "MultiPolygon not correct" % value) + pass - def _validate_type_geometrycollection(self, field, value): - """ Enables validation for `geometrycollection`data type - - :param field: field name. - :param value: field nvalue - """ + def _validate_type_geometrycollection(self, value): try: GeometryCollection(value) + return True except TypeError: - self._error(field, "GeometryCollection not correct" % value) + pass + + def _validate_type_feature(self, value): + """Enables validation for `feature`data type - def _error(self, field, _error): - """ Change the default behaviour so that, if VALIDATION_ERROR_AS_LIST - is enabled, single validation errors are returned as a list. See #536. + :param value: field value + """ + try: + Feature(value) + return True + except TypeError: + pass - :param field: field name - :param _error: field error(s) + def _validate_type_featurecollection(self, value): + """Enables validation for `featurecollection`data type - .. versionadded:: 0.6 + :param value: field value """ - super(Validator, self)._error(field, _error) - if config.VALIDATION_ERROR_AS_LIST: - err = self._errors[field] - if not isinstance(err, list): - self._errors[field] = [err] + try: + FeatureCollection(value) + return True + except TypeError: + pass diff --git a/eve/logging.py b/eve/logging.py index 7b6c1a657..1a1803157 100644 --- a/eve/logging.py +++ b/eve/logging.py @@ -1,15 +1,16 @@ from __future__ import absolute_import import logging -from flask import request +from flask import request # TODO right now we are only logging exceptions. We should probably # add support for some INFO and maybe DEBUG level logging (like, log each time # a endpoint is hit, etc.) + class RequestFilter(logging.Filter): - """ Adds Flask's request metadata to the log record so handlers can log + """Adds Flask's request metadata to the log record so handlers can log this information too. import logging @@ -24,7 +25,7 @@ class RequestFilter(logging.Filter): Note that the app.logger can also be used by callback functions. - def log_a_get(resoure, request, payload): + def log_a_get(resource, request, payload): app.logger.info('we just responded to a GET request!') app = Eve() @@ -33,6 +34,7 @@ def log_a_get(resoure, request, payload): .. versionadded:: 0.6 """ + def filter(self, record): if request: record.clientip = request.remote_addr diff --git a/eve/methods/__init__.py b/eve/methods/__init__.py index ad7e3d935..eaf7a9cb1 100644 --- a/eve/methods/__init__.py +++ b/eve/methods/__init__.py @@ -6,13 +6,13 @@ This package implements the HTTP methods supported by Eve. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ +from eve.methods.delete import delete, deleteitem # flake8: noqa from eve.methods.get import get, getitem -from eve.methods.post import post from eve.methods.patch import patch +from eve.methods.post import post from eve.methods.put import put -from eve.methods.delete import delete, deleteitem diff --git a/eve/methods/common.py b/eve/methods/common.py index 3d08d5687..5cdfd27e3 100644 --- a/eve/methods/common.py +++ b/eve/methods/common.py @@ -6,34 +6,46 @@ Utility functions for API methods implementations. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ import base64 -import simplejson as json +import re import time +from collections import Counter +from copy import copy +from datetime import datetime, timezone +from functools import cache, wraps +import simplejson as json from bson.dbref import DBRef from bson.errors import InvalidId -from copy import copy -from datetime import datetime -from eve.utils import auto_fields -from eve.utils import config -from eve.utils import debug_error_message -from eve.utils import document_etag -from eve.utils import parse_request -from eve.versioning import get_data_version_relation_document -from eve.versioning import resolve_document_version -from flask import Response +from cerberus import rules_set_registry, schema_registry from flask import abort from flask import current_app as app -from flask import g -from flask import request -from functools import wraps - - -def get_document(resource, concurrency_check, **lookup): - """ Retrieves and return a single document. Since this function is used by +from flask import g, request +from werkzeug.datastructures import CombinedMultiDict, MultiDict + +from eve.utils import ( + auto_fields, + config, + debug_error_message, + document_etag, + parse_request, +) +from eve.versioning import get_data_version_relation_document, resolve_document_version + + +def get_document( + resource, + concurrency_check, + original=None, + check_auth_value=True, + force_auth_field_projection=False, + mongo_options=None, + **lookup +): + """Retrieves and return a single document. Since this function is used by the editing methods (PUT, PATCH, DELETE), we make sure that the client request references the current representation of the document before returning it. However, this concurrency control may be turned off by @@ -42,6 +54,16 @@ def get_document(resource, concurrency_check, **lookup): :param resource: the name of the resource to which the document belongs to. :param concurrency_check: boolean check for concurrency control + :param original: in case the document was already retrieved before + :param check_auth_value: a boolean flag indicating if the find operation + should consider user-restricted resource + access. Defaults to ``True``. + :param force_auth_field_projection: a boolean flag indicating if the + find operation should always include + the user-restricted resource access + field (if configured). Defaults to + ``False``. + :param mongo_options: Options to pass to PyMongo. e.g. read_preferences. :param **lookup: document lookup query .. versionchanged:: 0.6 @@ -59,12 +81,23 @@ def get_document(resource, concurrency_check, **lookup): processing of new configuration settings: `filters`, `sorting`, `paging`. """ req = parse_request(resource) - if config.DOMAIN[resource]['soft_delete']: + if config.DOMAIN[resource]["soft_delete"]: # get_document should always fetch soft deleted documents from the db # callers must handle soft deleted documents req.show_deleted = True - document = app.data.find_one(resource, req, **lookup) + if original: + document = original + else: + document = app.data.find_one( + resource, + req, + check_auth_value, + force_auth_field_projection, + mongo_options=mongo_options, + **lookup + ) + if document: e_if_m = config.ENFORCE_IF_MATCH if_m = config.IF_MATCH @@ -73,8 +106,11 @@ def get_document(resource, concurrency_check, **lookup): # for the document or explicitly decides to allow editing by either # disabling the ``concurrency_check`` or ``IF_MATCH`` or # ``ENFORCE_IF_MATCH`` fields. - abort(428, description='To edit a document ' - 'its etag must be provided using the If-Match header') + abort( + 428, + description="To edit a document " + "its etag must be provided using the If-Match header", + ) # ensure the retrieved document has LAST_UPDATED and DATE_CREATED, # eventually with same default values as in GET. @@ -82,19 +118,20 @@ def get_document(resource, concurrency_check, **lookup): document[config.DATE_CREATED] = date_created(document) if req.if_match and concurrency_check: - ignore_fields = config.DOMAIN[resource]['etag_ignore_fields'] - etag = document.get(config.ETAG, document_etag(document, - ignore_fields=ignore_fields)) + ignore_fields = config.DOMAIN[resource]["etag_ignore_fields"] + etag = document.get( + config.ETAG, document_etag(document, ignore_fields=ignore_fields) + ) if req.if_match != etag: # client and server etags must match, or we don't allow editing # (ensures that client's version of the document is up to date) - abort(412, description='Client and server etags don\'t match') + abort(412, description="Client and server etags don't match") return document def parse(value, resource): - """ Safely evaluates a string containing a Python expression. We are + """Safely evaluates a string containing a Python expression. We are receiving json and returning a dict. :param value: the string to be evaluated. @@ -117,7 +154,7 @@ def parse(value, resource): try: # assume it's not decoded to json yet (request Content-Type = form) document = json.loads(value) - except: + except Exception: # already a json document = value @@ -126,14 +163,14 @@ def parse(value, resource): # formatted objectid). try: document = serialize(document, resource) - except: + except Exception: pass return document def payload(): - """ Performs sanity checks or decoding depending on the Content-Type, + """Performs sanity checks or decoding depending on the Content-Type, then returns the request payload as a dict. If request Content-Type is unsupported, aborts with a 400 (Bad Request). @@ -158,14 +195,17 @@ def payload(): .. versionadded: 0.0.5 """ - content_type = request.headers.get('Content-Type', '').split(';')[0] - - if content_type == 'application/json': - return request.get_json() - elif content_type == 'application/x-www-form-urlencoded': - return request.form.to_dict() if len(request.form) else \ - abort(400, description='No form-urlencoded data supplied') - elif content_type == 'multipart/form-data': + content_type = request.headers.get("Content-Type", "").split(";")[0] + + if content_type in config.JSON_REQUEST_CONTENT_TYPES: + return request.get_json(force=True) + if content_type == "application/x-www-form-urlencoded": + return ( + multidict_to_dict(request.form) + if len(request.form) + else abort(400, description="No form-urlencoded data supplied") + ) + if content_type == "multipart/form-data": # as multipart is also used for file uploads, we let an empty # request.form go through as long as there are also files in the # request. @@ -173,33 +213,41 @@ def payload(): # merge form fields and request files, so we get a single payload # to be validated against the resource schema. - if config.MULTIPART_FORM_FIELDS_AS_JSON: - - formItems = dict(list(request.form.to_dict().items())) - - for key in formItems.keys(): - try: - formItems[key] = json.loads(formItems[key]) - except ValueError: - formItems[key] = json.loads( - '"{0}"'.format(formItems[key])) - - return dict(list(formItems.items()) + - list(request.files.to_dict().items())) - else: - # list() is needed because Python3 items() returns a - # dict_view, not a list as in Python2. - return dict(list(request.form.to_dict().items()) + - list(request.files.to_dict().items())) + formItems = MultiDict(request.form) - else: - abort(400, description='No multipart/form-data supplied') - else: - abort(400, description='Unknown or no Content-Type header supplied') + if config.MULTIPART_FORM_FIELDS_AS_JSON: + for key, lst in formItems.lists(): + new_lst = [] + for value in lst: + try: + new_lst.append(json.loads(value)) + except ValueError: + new_lst.append(json.loads('"{0}"'.format(value))) + formItems.setlist(key, new_lst) + + payload = CombinedMultiDict([formItems, request.files]) + return multidict_to_dict(payload) + + abort(400, description="No multipart/form-data supplied") + abort(400, description="Unknown or no Content-Type header supplied") + + +def multidict_to_dict(multidict): + """Convert a MultiDict containing form data into a regular dict. If the + config setting AUTO_COLLAPSE_MULTI_KEYS is True, multiple values with the + same key get entered as a list. If it is False, the first entry is picked. + """ + if config.AUTO_COLLAPSE_MULTI_KEYS: + d = dict(multidict.lists()) + for key, value in d.items(): + if len(value) == 1: + d[key] = value[0] + return d + return multidict.to_dict() -class RateLimit(object): - """ Implements the Rate-Limiting logic using Redis as a backend. +class RateLimit: + """Implements the Rate-Limiting logic using Redis as a backend. :param key_prefix: the key used to uniquely identify a client. :param limit: requests limit, per period. @@ -209,6 +257,7 @@ class RateLimit(object): .. versionadded:: 0.0.7 """ + # Maybe has something complicated problems. def __init__(self, key, limit, period, send_x_headers=True): @@ -227,16 +276,16 @@ def __init__(self, key, limit, period, send_x_headers=True): def get_rate_limit(): - """ If available, returns a RateLimit instance which is valid for the + """If available, returns a RateLimit instance which is valid for the current request-response. .. versionadded:: 0.0.7 """ - return getattr(g, '_rate_limit', None) + return getattr(g, "_rate_limit", None) def ratelimit(): - """ Enables support for Rate-Limits on API methods + """Enables support for Rate-Limits on API methods The key is constructed by default from the remote address or the authorization.username if authentication is being used. On a authentication-only API, this will impose a ratelimit even on @@ -249,33 +298,38 @@ def ratelimit(): .. versionadded:: 0.0.7 """ + def decorator(f): @wraps(f) def rate_limited(*args, **kwargs): - method_limit = app.config.get('RATE_LIMIT_' + request.method) + method_limit = app.config.get("RATE_LIMIT_" + request.method) if method_limit and app.redis: limit = method_limit[0] period = method_limit[1] # If authorization is being used the key is 'username'. # Else, fallback to client IP. - key = 'rate-limit/%s' % (request.authorization.username - if request.authorization else - request.remote_addr) + key = "rate-limit/%s" % ( + request.authorization.username + if request.authorization + else request.remote_addr + ) rlimit = RateLimit(key, limit, period, True) if rlimit.over_limit: - return Response('Rate limit exceeded', 429) + abort(429, "Rate limit exceeded") # store the rate limit for further processing by # send_response g._rate_limit = rlimit else: g._rate_limit = None return f(*args, **kwargs) + return rate_limited + return decorator def last_updated(document): - """ Fixes document's LAST_UPDATED field value. Flask-PyMongo returns + """Fixes document's LAST_UPDATED field value. Flask-PyMongo returns timezone-aware values while stdlib datetime values are timezone-naive. Comparisons between the two would fail. @@ -294,12 +348,11 @@ def last_updated(document): """ if config.LAST_UPDATED in document: return document[config.LAST_UPDATED].replace(tzinfo=None) - else: - return epoch() + return epoch() def date_created(document): - """ If DATE_CREATED is missing we assume that it has been created outside + """If DATE_CREATED is missing we assume that it has been created outside of the API context and inject a default value. By design all documents return a DATE_CREATED (and we dont' want to break existing clients). @@ -311,12 +364,11 @@ def date_created(document): .. versionadded:: 0.0.5 """ - return document[config.DATE_CREATED] if config.DATE_CREATED in document \ - else epoch() + return document[config.DATE_CREATED] if config.DATE_CREATED in document else epoch() def epoch(): - """ A datetime.min alternative which won't crash on us. + """A datetime.min alternative which won't crash on us. .. versionchanged:: 0.1.0 Moved to common.py and renamed as public, so it can also be used by edit @@ -328,9 +380,12 @@ def epoch(): def serialize(document, resource=None, schema=None, fields=None): - """ Recursively handles field values that require data-aware serialization. + """Recursively handles field values that require data-aware serialization. Relies on the app.data.serializers dictionary. + .. versionchanged: 0.8.1 + Normalize dotted fields according to normalized_dotted_fields. See #1173. + .. versionchanged:: 0.7 Add support for normalizing anyof-like rules inside lists. See #876. @@ -353,11 +408,18 @@ def serialize(document, resource=None, schema=None, fields=None): .. versionadded:: 0.1.1 """ - normalize_dotted_fields(document) + def resolve_schema(schema): + return schema if isinstance(schema, dict) else schema_registry.get(schema) + + if ( + resource not in config.DOMAIN + or config.DOMAIN[resource]["normalize_dotted_fields"] + ): + normalize_dotted_fields(document) if app.data.serializers: if resource: - schema = config.DOMAIN[resource]['schema'] + schema = resolve_schema(config.DOMAIN[resource]["schema"]) if not fields: fields = document.keys() for field in fields: @@ -365,90 +427,118 @@ def serialize(document, resource=None, schema=None, fields=None): continue if field in schema: field_schema = schema[field] - field_type = field_schema.get('type') - if field_type is None: - for x_of in ['allof', 'anyof', 'oneof', 'noneof']: + if not isinstance(field_schema, dict): + field_schema = rules_set_registry.get(field_schema) + field_types = field_schema.get("type") + if not isinstance(field_types, list): + field_types = [field_types] + for field_type in field_types: + for x_of in ["allof", "anyof", "oneof", "noneof"]: for optschema in field_schema.get(x_of, []): - schema = {field: optschema} - serialize(document, schema=schema) - x_of_type = '{0}_type'.format(x_of) + optschema = dict(field_schema, **optschema) + optschema.pop(x_of, None) + serialize(document, schema={field: optschema}) + x_of_type = "{0}_type".format(x_of) for opttype in field_schema.get(x_of_type, []): - schema = {field: {'type': opttype}} - serialize(document, schema=schema) - if 'schema' in field_schema: - field_schema = field_schema['schema'] - if 'dict' in (field_type, field_schema.get('type')): - # either a dict or a list of dicts - embedded = [document[field]] if field_type == 'dict' \ - else document[field] - for subdocument in embedded: - if type(subdocument) is not dict: - # value is not a dict - continue serialization - # error will be reported by validation if - # appropriate - continue - elif 'schema' in field_schema: - serialize(subdocument, - schema=field_schema['schema']) - else: - serialize(subdocument, schema=field_schema) - elif field_schema.get('type') == 'list': - # a list of lists - sublist_schema = field_schema.get('schema') - item_type = sublist_schema.get('type') - for sublist in document[field]: - for i, v in enumerate(sublist): - if item_type == 'dict': - serialize(sublist[i], - schema=sublist_schema['schema']) - elif item_type in app.data.serializers: - sublist[i] = serialize_value(item_type, v) - elif field_schema.get('type') is None: - # a list of items determined by *of rules - for x_of in ['allof', 'anyof', 'oneof', 'noneof']: - for optschema in field_schema.get(x_of, []): - schema = {field: { - 'type': field_type, - 'schema': optschema}} - serialize(document, schema=schema) - x_of_type = '{0}_type'.format(x_of) - for opttype in field_schema.get(x_of_type, []): - schema = {field: { - 'type': field_type, - 'schema': {'type': opttype}}} - serialize(document, schema=schema) - else: - # a list of one type, arbitrary length - field_type = field_schema.get('type') - if field_type in app.data.serializers: - for i, v in enumerate(document[field]): - document[field][i] = \ - serialize_value(field_type, v) - elif 'items' in field_schema: - # a list of multiple types, fixed length - for i, (s, v) in enumerate(zip(field_schema['items'], - document[field])): - field_type = s.get('type') - if field_type in app.data.serializers: - document[field][i] = \ - serialize_value(field_type, document[field][i]) - elif 'valueschema' in field_schema: - # a valueschema - field_type = field_schema['valueschema']['type'] - if field_type == 'objectid': - target = document[field] - for field in target: - target[field] = \ - serialize_value(field_type, target[field]) - elif field_type == 'dict': - for subdocument in document[field].values(): - serialize( - subdocument, - schema=field_schema['valueschema']['schema']) - elif field_type in app.data.serializers: - # a simple field - document[field] = \ - serialize_value(field_type, document[field]) + optschema = dict(field_schema, type=opttype) + optschema.pop(x_of_type, None) + serialize(document, schema={field: optschema}) + if config.AUTO_CREATE_LISTS and field_type == "list": + # Convert single values to lists + if not isinstance(document[field], list): + document[field] = [document[field]] + if "schema" in field_schema: + field_schema = resolve_schema(field_schema["schema"]) + if "dict" in (field_type, field_schema.get("type")): + # either a dict or a list of dicts + embedded = ( + [document[field]] + if field_type == "dict" + else document[field] + ) + for subdocument in embedded: + if not isinstance(subdocument, dict): + # value is not a dict - continue + # serialization error will be reported by + # validation if appropriate + continue + if "schema" in field_schema: + serialize( + subdocument, schema=field_schema["schema"] + ) + else: + serialize(subdocument, schema=field_schema) + elif field_schema.get("type") == "list": + # a list of lists + sublist_schema = resolve_schema(field_schema.get("schema")) + item_type = sublist_schema.get("type") + for sublist in document[field]: + for i, v in enumerate(sublist): + if item_type == "dict": + serialize( + sublist[i], schema=sublist_schema["schema"] + ) + elif item_type in app.data.serializers: + sublist[i] = serialize_value(item_type, v) + elif field_schema.get("type") is None: + # a list of items determined by *of rules + for x_of in ["allof", "anyof", "oneof", "noneof"]: + for optschema in field_schema.get(x_of, []): + serialize( + document, + schema={ + field: { + "type": field_type, + "schema": optschema, + } + }, + ) + x_of_type = "{0}_type".format(x_of) + for opttype in field_schema.get(x_of_type, []): + serialize( + document, + schema={ + field: { + "type": field_type, + "schema": {"type": opttype}, + } + }, + ) + else: + # a list of one type, arbitrary length + field_type = field_schema.get("type") + if field_type in app.data.serializers: + for i, v in enumerate(document[field]): + document[field][i] = serialize_value(field_type, v) + elif "items" in field_schema: + # a list of multiple types, fixed length + for i, (s, v) in enumerate( + zip(field_schema["items"], document[field]) + ): + field_type = s.get("type") + if field_type in app.data.serializers: + document[field][i] = serialize_value( + field_type, document[field][i] + ) + elif "valueschema" in field_schema: + # a valueschema + field_type = field_schema["valueschema"]["type"] + if field_type == "objectid": + target = document[field] + for field in target: + target[field] = serialize_value( + field_type, target[field] + ) + elif field_type == "dict": + for subdocument in document[field].values(): + serialize( + subdocument, + schema=field_schema["valueschema"]["schema"], + ) + + elif field_type in app.data.serializers: + # a simple field + document[field] = serialize_value(field_type, document[field]) return document @@ -466,10 +556,10 @@ def serialize_value(field_type, value): def normalize_dotted_fields(document): - """ Normalizes eventual dotted fields so validation can be performed + """Normalizes eventual dotted fields so validation can be performed seamlessly. For example this document: - {"location.city": "a nested cisty"} + {"location.city": "a nested city"} would be normalized to: @@ -495,8 +585,8 @@ def normalize_dotted_fields(document): normalize_dotted_fields(i) elif isinstance(document, dict): for field in list(document): - if '.' in field: - parts = field.split('.') + if "." in field: + parts = field.split(".") prev = document for part in parts[:-1]: if part not in prev: @@ -510,9 +600,8 @@ def normalize_dotted_fields(document): normalize_dotted_fields(document[field]) -def build_response_document( - document, resource, embedded_fields, latest_doc=None): - """ Prepares a document for response including generation of ETag and +def build_response_document(document, resource, embedded_fields, latest_doc=None): + """Prepares a document for response including generation of ETag and metadata fields. :param document: the document to embed other documents into. @@ -520,6 +609,9 @@ def build_response_document( :param embedded_fields: the list of fields we are allowed to embed. :param document: the latest version of document. + .. versionchanged:: 0.8.2 + Add data relation fields hateoas support (#1204). + .. versionchanged:: 0.5 Only compute ETAG if necessary (#369). Add version support (#475). @@ -528,6 +620,8 @@ def build_response_document( """ resource_def = config.DOMAIN[resource] + resolve_resource_projection(document, resource) + # need to update the document field since the etag must be computed on the # same document representation that might have been used in the collection # 'get' method @@ -536,33 +630,36 @@ def build_response_document( # Up to v0.4 etags were not stored with the documents. if config.IF_MATCH and config.ETAG not in document: - ignore_fields = resource_def['etag_ignore_fields'] - document[config.ETAG] = document_etag(document, - ignore_fields=ignore_fields) + ignore_fields = resource_def["etag_ignore_fields"] + document[config.ETAG] = document_etag(document, ignore_fields=ignore_fields) # hateoas links - if resource_def['hateoas'] and resource_def['id_field'] in document: + if resource_def["hateoas"] and resource_def["id_field"] in document: version = None - if resource_def['versioning'] is True \ - and request.args.get(config.VERSION_PARAM): + if resource_def["versioning"] is True and request.args.get( + config.VERSION_PARAM + ): version = document[config.VERSION] - self_dict = {'self': document_link(resource, - document[resource_def['id_field']], - version)} + self_dict = { + "self": document_link(resource, document[resource_def["id_field"]], version) + } if config.LINKS not in document: document[config.LINKS] = self_dict - elif 'self' not in document[config.LINKS]: + elif "self" not in document[config.LINKS]: document[config.LINKS].update(self_dict) + # add data relation links if hateoas enabled + resolve_data_relation_links(document, resource) + # add version numbers - resolve_document_version(document, resource, 'GET', latest_doc) + resolve_document_version(document, resource, "GET", latest_doc) # resolve media resolve_media_files(document, resource) # resolve soft delete - if resource_def['soft_delete'] is True: + if resource_def["soft_delete"] is True: if document.get(config.DELETED) is None: document[config.DELETED] = False elif document[config.DELETED] is True: @@ -574,38 +671,136 @@ def build_response_document( resolve_embedded_documents(document, resource, embedded_fields) +def resolve_resource_projection(document, resource): + """Purges a document of fields that are not included in its resource + projecton. + + :param document: the original document. + :param resource: the resource name. + """ + + if config.BANDWIDTH_SAVER: + return + + resource_def = config.DOMAIN[resource] + projection = resource_def["datasource"]["projection"] + projection_enabled = resource_def["projection"] + # Fix for #1338 + if not projection_enabled or not projection: + # BANDWIDTH_SAVER is disabled, and no projection is defined or + # projection feature is disabled, so return entire document. + return + fields = { + field for field, value in projection.items() if value and field in document + } + fields.add(resource_def["id_field"]) + + for field in set(document.keys()) - fields: + del document[field] + + +@cache def field_definition(resource, chained_fields): - """ Resolves query string to resource with dot notation like + """Resolves query string to resource with dot notation like 'people.address.city' and returns corresponding field definition of the resource :param resource: the resource name whose field to be accepted. :param chained_fields: query string to retrieve field definition + .. versionchanged:: 0.8.2 + fix field definition for list without a schema. See #1204. + .. versionadded 0.5 """ definition = config.DOMAIN[resource] - subfields = chained_fields.split('.') + subfields = chained_fields.split(".") for field in subfields: - if field not in definition.get('schema', {}): - if 'data_relation' in definition: - sub_resource = definition['data_relation']['resource'] + if field not in definition.get("schema", {}): + if "data_relation" in definition: + sub_resource = definition["data_relation"]["resource"] definition = config.DOMAIN[sub_resource] - if field not in definition['schema']: + if field not in definition["schema"]: return - definition = definition['schema'][field] - field_type = definition.get('type') - if field_type == 'list': - definition = definition['schema'] - elif field_type == 'objectid': + definition = definition["schema"][field] + field_type = definition.get("type") + if field_type == "list": + # the list can be 1) a list of allowed values for string and list types + # 2) a list of references that have schema + # we want to resolve field definition deeper for the second one + definition = definition.get("schema", definition) + elif field_type == "objectid": pass return definition +def resolve_data_relation_links(document, resource): + """Resolves all fields in a document that has data relation to other resources + + :param document: the document to include data relation links. + :param resource: the resource name. + + .. versionadded:: 0.8.2 + """ + resource_def = config.DOMAIN[resource] + related_dict = {} + + for field in resource_def.get("schema", {}): + field_def = field_definition(resource, field) + if "data_relation" not in field_def: + continue + + if field in document and document[field] is not None and document[field] != []: + related_links = [] + + # Make the code DRY for list of linked relation and single linked relation + for related_document_id in ( + document[field] + if isinstance(document[field], list) + else [document[field]] + ): + # Get the resource endpoint string for the linked relation + related_resource = ( + related_document_id.collection + if isinstance(related_document_id, DBRef) + else field_def["data_relation"]["resource"] + ) + + # Get the item endpoint id for the linked relation + if isinstance(related_document_id, DBRef): + related_document_id = related_document_id.id + if isinstance(related_document_id, dict): + related_resource_field = field_definition(resource, field)[ + "data_relation" + ]["field"] + related_document_id = related_document_id[related_resource_field] + + # Get the version for the item endpoint id + related_version = ( + related_document_id.get("_version") + if isinstance(related_document_id, dict) + else None + ) + + related_links.append( + document_link( + related_resource, related_document_id, related_version + ) + ) + + if isinstance(document[field], list): + related_dict.update({field: related_links}) + else: + related_dict.update({field: related_links[0]}) + + if related_dict: + document[config.LINKS].update({"related": related_dict}) + + def resolve_embedded_fields(resource, req): - """ Returns a list of validated embedded fields from the incoming request + """Returns a list of validated embedded fields from the incoming request or from the resource definition is the request does not specify. :param resource: the resource name. @@ -624,92 +819,232 @@ def resolve_embedded_fields(resource, req): try: client_embedding = json.loads(req.embedded) except ValueError: - abort(400, description='Unable to parse `embedded` clause') + abort(400, description="Unable to parse `embedded` clause") # Build the list of fields where embedding is being requested try: - embedded_fields = [k for k, v in client_embedding.items() - if v == 1] - non_embedded_fields = [k for k, v in client_embedding.items() - if v == 0] + embedded_fields = [k for k, v in client_embedding.items() if v == 1] + non_embedded_fields = [k for k, v in client_embedding.items() if v == 0] except AttributeError: # We got something other than a dict - abort(400, description='Unable to parse `embedded` clause') + abort(400, description="Unable to parse `embedded` clause") embedded_fields = list( - (set(config.DOMAIN[resource]['embedded_fields']) | - set(embedded_fields)) - set(non_embedded_fields)) + (set(config.DOMAIN[resource]["embedded_fields"]) | set(embedded_fields)) + - set(non_embedded_fields) + ) # For each field, is the field allowed to be embedded? # Pick out fields that have a `data_relation` where `embeddable=True` enabled_embedded_fields = [] - for field in sorted(embedded_fields, key=lambda a: a.count('.')): + for field in sorted(embedded_fields, key=lambda a: a.count(".")): # Reject bogus field names field_def = field_definition(resource, field) if field_def: - if field_def.get('type') == 'list': - field_def = field_def['schema'] - if 'data_relation' in field_def and \ - field_def['data_relation'].get('embeddable'): + if field_def.get("type") == "list": + field_def = field_def["schema"] + if "data_relation" in field_def and field_def["data_relation"].get( + "embeddable" + ): # or could raise 400 here enabled_embedded_fields.append(field) return enabled_embedded_fields -def embedded_document(reference, data_relation, field_name): - """ Returns a document to be embedded by reference using data_relation - taking into account document versions +def embedded_document(references, data_relation, field_name): + """Returns a document to be embedded by reference using data_relation + taking into account document versions - :param reference: reference to the document to be embedded. - :param data_relation: the relation schema definition. - :param field_name: field name used in abort message only + :param reference: reference to the document to be embedded. + :param data_relation: the relation schema definition. + :param field_name: field name used in abort message only - .. versionadded:: 0.5 + ) .. versionadded:: 0.5 """ - # Retrieve and serialize the requested document - if 'version' in data_relation and data_relation['version'] is True: - # grab the specific version - embedded_doc = get_data_version_relation_document( - data_relation, reference) - - # grab the latest version - latest_embedded_doc = get_data_version_relation_document( - data_relation, reference, latest=True) - - # make sure we got the documents - if embedded_doc is None or latest_embedded_doc is None: - # your database is not consistent!!! that is bad - # TODO: we should notify the developers with a log. - abort(404, description=debug_error_message( - "Unable to locate embedded documents for '%s'" % - field_name - )) - - build_response_document(embedded_doc, data_relation['resource'], - [], latest_embedded_doc) - else: - # if reference is DBRef take the referenced collection as subresource - subresource = reference.collection if isinstance(reference, DBRef) \ - else data_relation['resource'] - id_field = config.DOMAIN[subresource]['id_field'] - embedded_doc = app.data.find_one(subresource, None, - **{id_field: reference.id - if isinstance(reference, DBRef) - else reference}) - if embedded_doc: - resolve_media_files(embedded_doc, subresource) + embedded_docs = [] + + output_is_list = True - return embedded_doc + if not isinstance(references, list): + output_is_list = False + references = [references] + # Retrieve and serialize the requested document + if "version" in data_relation and data_relation["version"] is True: + # For the version flow, I keep the as-is logic (flow is too complex to + # make it bulk) + for reference in references: + # grab the specific version + embedded_doc = get_data_version_relation_document(data_relation, reference) + + # grab the latest version + latest_embedded_doc = get_data_version_relation_document( + data_relation, reference, latest=True + ) + + # make sure we got the documents + if embedded_doc is None or latest_embedded_doc is None: + # your database is not consistent!!! that is bad + # TODO: we should notify the developers with a log. + abort( + 404, + description=debug_error_message( + "Unable to locate embedded documents for '%s'" % field_name + ), + ) + + build_response_document( + embedded_doc, data_relation["resource"], [], latest_embedded_doc + ) + embedded_docs.append(embedded_doc) + else: + ( + id_value_to_sort, + list_of_id_field_name, + subresources_query, + ) = generate_query_and_sorting_criteria(data_relation, references) + for subresource in subresources_query: + result, _ = app.data.find( + subresource, None, subresources_query[subresource] + ) + list_embedded_doc = list(result) + + if not list_embedded_doc: + embedded_docs.extend( + [None] * len(subresources_query[subresource]["$or"]) + ) + else: + for embedded_doc in list_embedded_doc: + resolve_media_files(embedded_doc, subresource) + embedded_docs.extend(list_embedded_doc) + + # After having retrieved my data, I have to be sure that the sorting of + # the list is the same in input as in output (this is to support + # embedding of sub-documents - only in case the storage is not done via + # DBref) + if embedded_docs: + embedded_docs = sort_db_response( + embedded_docs, id_value_to_sort, list_of_id_field_name + ) + + if output_is_list: + return embedded_docs + if embedded_docs: + return embedded_docs[0] + return None + + +def sort_db_response(embedded_docs, id_value_to_sort, list_of_id_field_name): + """Sorts the documents fetched from the database + + :param embedded_docs: the documents fetch from the database. + :param id_value_to_sort: id_value sort criteria. + :param list_of_id_field_name: list of name of fields + :return embedded_docs: the list of documents sorted as per input + """ -def subdocuments(fields_chain, resource, document): - """ Traverses the given document and yields subdocuments which + id_field_name_occurrences = Counter(list_of_id_field_name) + temp_embedded_docs = [] + old_occurrence = 0 + + for id_field_name in set(list_of_id_field_name): + current_occurrence = old_occurrence + int( + id_field_name_occurrences[id_field_name] + ) + temp_embedded_docs.extend( + sort_per_resource( + embedded_docs[old_occurrence:current_occurrence], + id_value_to_sort, + id_field_name, + ) + ) + old_occurrence = current_occurrence + + return temp_embedded_docs + + +def sort_per_resource(embedded_docs, id_values_to_sort, id_field_name): + """Sorts the documents fetched from the database per single resource + + :param embedded_docs: list of the documents fetched from the database. + :param id_values_to_sort: list of the id_values sort criteria. + :param list_of_id_field_name: list of name of fields + :param id_field_name: key name of the id field; `_id` + :return embedded_docs: the list of documents sorted as per input + """ + if id_values_to_sort is None: + id_values_to_sort = [] + embedded_docs = [x for x in embedded_docs if x is not None] + id2dict = dict((d[id_field_name], d) for d in embedded_docs) + temporary_embedded_docs = [] + for id_value_ in id_values_to_sort: + if id_value_ in id2dict: + temporary_embedded_docs.append(id2dict[id_value_]) + + return temporary_embedded_docs + + +def generate_query_and_sorting_criteria(data_relation, references): + """Generate query and sorting critiria + + :param data_relation: data relation for the resource. + :param references: DBRef or id to use to embed the document. + :returns id_value_to_sort: list of ids to use in the sort + list_of_id_field_name: list of field name (important only for + DBRef) + subresources_query: the list of query to perform per resource + (in case is not DBRef, it will be only one + query) + """ + query = {"$or": []} + subresources_query = {} + old_subresource = "" + id_value_to_sort = [] + # id_field name should be the same for + # all the elements in the list + list_of_id_field_name = [] + for counter, reference in enumerate(references): + # if reference is DBRef take the referenced collection as subresource + # NOTE: using DBRef, I can define several resource for each link + subresource = ( + reference.collection + if isinstance(reference, DBRef) + else data_relation["resource"] + ) + if old_subresource and old_subresource != subresource: + add_query_to_list(query, subresource, subresources_query) + # NOTE: in case it is a DBRef link, the id_field_name is always the _id + # regardless the Eve set-up + id_field_name = ( + "_id" + if isinstance(reference, DBRef) + else data_relation.get("field", False) + or config.DOMAIN[subresource]["id_field"] + ) + id_field_value = reference.id if isinstance(reference, DBRef) else reference + query["$or"].append({id_field_name: id_field_value}) + id_value_to_sort.append(id_field_value) + list_of_id_field_name.append(id_field_name) + if counter == len(references) - 1: + add_query_to_list(query, subresource, subresources_query) + return id_value_to_sort, list_of_id_field_name, subresources_query + + +def add_query_to_list(query, subresource, subresource_query): + subresource_query.update({subresource: copy(query)}) + query.clear() + query["$or"] = [] + + +def subdocuments(fields_chain, resource, document, prefix=""): + """Traverses the given document and yields subdocuments which correspond to the given fields_chain :param fields_chain: list of nested field names. :param resource: the resource name. :param document: document to be traversed + :param prefix: prefix to recursively concatenate nested field names. .. versionadded:: 0.5 """ @@ -719,20 +1054,24 @@ def subdocuments(fields_chain, resource, document): subdocument = document[fields_chain[0]] docs = subdocument if isinstance(subdocument, list) else [subdocument] try: - resource = field_definition( - resource, fields_chain[0])['data_relation']['resource'] + definition = field_definition(resource, prefix + fields_chain[0]) + if "data_relation" in definition: + resource = definition["data_relation"]["resource"] + prefix = "" + else: + prefix = prefix + fields_chain[0] + "." except KeyError: resource = resource for doc in docs: - for result in subdocuments(fields_chain[1:], resource, doc): + for result in subdocuments(fields_chain[1:], resource, doc, prefix): yield result else: yield document def resolve_embedded_documents(document, resource, embedded_fields): - """ Loops through the documents, adding embedded representations + """Loops through the documents, adding embedded representations of any fields that are (1) defined eligible for embedding in the DOMAIN and (2) requested to be embedded in the current `req`. @@ -747,40 +1086,36 @@ def resolve_embedded_documents(document, resource, embedded_fields): :param resource: the resource name. :param embedded_fields: the list of fields we are allowed to embed. - .. versionchagend:: 0.5 + .. versionchanged:: 0.5 Support for embedding documents located in subdocuments. Allocated two functions embedded_document and subdocuments. - .. versionchagend:: 0.4 + .. versionchanged:: 0.4 Moved parsing of embedded fields to _resolve_embedded_fields. Support for document versioning. - .. versionchagend:: 0.2 + .. versionchanged:: 0.2 Support for 'embedded_fields'. - .. versonchanged:: 0.1.1 + .. versionchanged:: 0.1.1 'collection' key has been renamed to 'resource' (data_relation). .. versionadded:: 0.1.0 """ # NOTE(Gonéri): We resolve the embedded documents at the end. - for field in sorted(embedded_fields, key=lambda a: a.count('.')): - data_relation = field_definition(resource, field)['data_relation'] + for field in sorted(embedded_fields, key=lambda a: a.count(".")): + data_relation = field_definition(resource, field)["data_relation"] getter = lambda ref: embedded_document(ref, data_relation, field) # noqa - fields_chain = field.split('.') + fields_chain = field.split(".") last_field = fields_chain[-1] for subdocument in subdocuments(fields_chain[:-1], resource, document): - if last_field not in subdocument: + if not subdocument or last_field not in subdocument: continue - if isinstance(subdocument[last_field], list): - subdocument[last_field] = list(map(getter, - subdocument[last_field])) - else: - subdocument[last_field] = getter(subdocument[last_field]) + subdocument[last_field] = getter(subdocument[last_field]) def resolve_media_files(document, resource): - """ Embed media files into the response document. + """Embed media files into the response document. :param document: the document eventually containing the media files. :param resource: the resource being consumed by the request. @@ -788,47 +1123,58 @@ def resolve_media_files(document, resource): .. versionadded:: 0.4 """ for field in resource_media_fields(document, resource): - file_id = document[field] - _file = app.media.get(file_id, resource) - - if _file: - # otherwise we have a valid file and should send extended response - # start with the basic file object - if config.RETURN_MEDIA_AS_BASE64_STRING: - ret_file = base64.encodestring(_file.read()) - elif config.RETURN_MEDIA_AS_URL: - prefix = config.MEDIA_BASE_URL if config.MEDIA_BASE_URL \ - is not None else app.api_prefix - ret_file = '%s/%s/%s' % (prefix, config.MEDIA_ENDPOINT, - file_id) - else: - ret_file = None - - if config.EXTENDED_MEDIA_INFO: - document[field] = { - 'file': ret_file, - } - - # check if we should return any special fields - for attribute in config.EXTENDED_MEDIA_INFO: - if hasattr(_file, attribute): - # add extended field if found in the file object - document[field].update({ - attribute: getattr(_file, attribute) - }) - else: - # tried to select an invalid attribute - abort(500, description=debug_error_message( - 'Invalid extended media attribute requested' - )) - else: - document[field] = ret_file + if isinstance(document[field], list): + resolved_list = [] + for file_id in document[field]: + resolved_list.append(resolve_one_media(file_id, resource)) + document[field] = resolved_list + else: + document[field] = resolve_one_media(document[field], resource) + + +def resolve_one_media(file_id, resource): + """Get response for one media file""" + _file = app.media.get(file_id, resource) + + if _file: + # otherwise we have a valid file and should send extended response + # start with the basic file object + if config.RETURN_MEDIA_AS_BASE64_STRING: + ret_file = base64.b64encode(_file.read()) + elif config.RETURN_MEDIA_AS_URL: + prefix = ( + config.MEDIA_BASE_URL + if config.MEDIA_BASE_URL is not None + else app.api_prefix + ) + ret_file = "%s/%s/%s" % (prefix, config.MEDIA_ENDPOINT, file_id) else: - document[field] = None + ret_file = None + + if config.EXTENDED_MEDIA_INFO: + ret = {"file": ret_file} + + # check if we should return any special fields + for attribute in config.EXTENDED_MEDIA_INFO: + if hasattr(_file, attribute): + # add extended field if found in the file object + ret.update({attribute: getattr(_file, attribute)}) + else: + # tried to select an invalid attribute + abort( + 500, + description=debug_error_message( + "Invalid extended media attribute requested" + ), + ) + + return ret + return ret_file + return None def marshal_write_response(document, resource): - """ Limit response document to minimize bandwidth when client supports it. + """Limit response document to minimize bandwidth when client supports it. :param document: the response document. :param resource: the resource being consumed by the request. @@ -839,26 +1185,26 @@ def marshal_write_response(document, resource): .. versionadded:: 0.4 """ - resource_def = app.config['DOMAIN'][resource] - if app.config['BANDWIDTH_SAVER'] is True: + resource_def = app.config["DOMAIN"][resource] + if app.config["BANDWIDTH_SAVER"] is True: # only return the automatic fields and special extra fields - fields = auto_fields(resource) + resource_def['extra_response_fields'] + fields = auto_fields(resource) + resource_def["extra_response_fields"] document = dict((k, v) for (k, v) in document.items() if k in fields) else: # avoid exposing the auth_field if it is not included in the # resource schema. - auth_field = resource_def.get('auth_field') - if auth_field and auth_field not in resource_def['schema']: + auth_field = resource_def.get("auth_field") + if auth_field and auth_field not in resource_def["schema"]: try: - del(document[auth_field]) - except: + del document[auth_field] + except Exception: # 'auth_field' value has not been set by the auth class. pass return document def store_media_files(document, resource, original=None): - """ Store any media file in the underlying media store and update the + """Store any media file in the underlying media store and update the document with unique ids of stored files. :param document: the document eventually containing the media files. @@ -871,32 +1217,52 @@ def store_media_files(document, resource, original=None): .. versionadded:: 0.3 """ # TODO We're storing media files in advance, before the corresponding - # document is also stored. In the rare occurance that the subsequent + # document is also stored. In the rare occurrence that the subsequent # document update fails we should probably attempt a cleanup on the storage - # sytem. Easier said than done though. + # system. Easier said than done though. for field in resource_media_fields(document, resource): if original and field in original: # since file replacement is not supported by the media storage - # system, we first need to delete the file being replaced. - app.media.delete(original[field], resource) + # system, we first need to delete the files being replaced. + if isinstance(original[field], list): + for file_id in original[field]: + app.media.delete(file_id, resource) + else: + app.media.delete(original[field], resource) if document[field]: - # store file and update document with file's unique id/filename + # store files and update document with file's unique id/filename # also pass in mimetype for use when retrieving the file - document[field] = app.media.put( - document[field], filename=document[field].filename, - content_type=document[field].mimetype, resource=resource) + if isinstance(document[field], list): + id_lst = [] + for stor_obj in document[field]: + id_lst.append( + app.media.put( + stor_obj, + filename=stor_obj.filename, + content_type=stor_obj.mimetype, + resource=resource, + ) + ) + document[field] = id_lst + else: + document[field] = app.media.put( + document[field], + filename=document[field].filename, + content_type=document[field].mimetype, + resource=resource, + ) def resource_media_fields(document, resource): - """ Returns a list of media fields defined in the resource schema. + """Returns a list of media fields defined in the resource schema. :param document: the document eventually containing the media files. :param resource: the resource being consumed by the request. .. versionadded:: 0.3 """ - media_fields = app.config['DOMAIN'][resource]['_media'] + media_fields = app.config["DOMAIN"][resource]["_media"] return [field for field in media_fields if field in document] @@ -905,10 +1271,10 @@ def resolve_sub_resource_path(document, resource): return resource_def = config.DOMAIN[resource] - schema = resource_def['schema'] + schema = resource_def["schema"] fields = [] for field, value in request.view_args.items(): - if field in schema and field != resource_def['id_field']: + if field in schema and field != resource_def["id_field"]: fields.append(field) document[field] = value @@ -917,7 +1283,7 @@ def resolve_sub_resource_path(document, resource): def resolve_user_restricted_access(document, resource): - """ Adds user restricted access medadata to the document if applicable. + """Adds user restricted access metadata to the document if applicable. :param document: the document being posted or replaced :param resource: the resource to which the document belongs @@ -932,9 +1298,9 @@ def resolve_user_restricted_access(document, resource): """ # if 'user-restricted resource access' is enabled and there's # an Auth request active, inject the username into the document - resource_def = app.config['DOMAIN'][resource] - auth = resource_def['authentication'] - auth_field = resource_def['auth_field'] + resource_def = app.config["DOMAIN"][resource] + auth = resource_def["authentication"] + auth_field = resource_def["auth_field"] if auth and auth_field: request_auth_value = auth.get_request_auth_value() if request_auth_value: @@ -942,23 +1308,22 @@ def resolve_user_restricted_access(document, resource): def resolve_document_etag(documents, resource): - """ Adds etags to documents. + """Adds etags to documents. .. versionadded:: 0.5 """ if config.IF_MATCH: - ignore_fields = config.DOMAIN[resource]['etag_ignore_fields'] + ignore_fields = config.DOMAIN[resource]["etag_ignore_fields"] if not isinstance(documents, list): documents = [documents] for document in documents: - document[config.ETAG] =\ - document_etag(document, ignore_fields=ignore_fields) + document[config.ETAG] = document_etag(document, ignore_fields=ignore_fields) def pre_event(f): - """ Enable a Hook pre http request. + """Enable a Hook pre http request. .. versionchanged:: 0.6 Enable callback hooks for HEAD requests. @@ -970,20 +1335,26 @@ def pre_event(f): .. versionadded:: 0.2 """ + @wraps(f) def decorated(*args, **kwargs): method = request.method - if method == 'HEAD': - method = 'GET' + if method == "HEAD": + method = "GET" - event_name = 'on_pre_' + method + event_name = "on_pre_" + method resource = args[0] if args else None gh_params = () rh_params = () - if method in ('GET', 'PATCH', 'DELETE', 'PUT'): - gh_params = (resource, request, kwargs) - rh_params = (request, kwargs) - elif method in ('POST', ): + combined_args = kwargs + + if len(args) > 1: + combined_args.update(args[1].items()) + + if method in ("GET", "PATCH", "DELETE", "PUT"): + gh_params = (resource, request, combined_args) + rh_params = (request, combined_args) + elif method in ("POST",): # POST hook does not support the kwargs argument gh_params = (resource, request) rh_params = (request,) @@ -992,23 +1363,24 @@ def decorated(*args, **kwargs): getattr(app, event_name)(*gh_params) if resource: # resource hook - getattr(app, event_name + '_' + resource)(*rh_params) + getattr(app, event_name + "_" + resource)(*rh_params) - combined_args = kwargs - if len(args) > 1: - combined_args.update(args[1].items()) r = f(resource, **combined_args) return r + return decorated def document_link(resource, document_id, version=None): - """ Returns a link to a document endpoint. + """Returns a link to a document endpoint. :param resource: the resource name. :param document_id: the document unique identifier. :param version: the document version. Defaults to None. + .. versionchanged:: 0.8.2 + Support document link for data relation resources. See #1204. + .. versionchanged:: 0.5 Add version support (#475). @@ -1021,46 +1393,59 @@ def document_link(resource, document_id, version=None): .. versionchanged:: 0.0.3 Now returning a JSON link """ - version_part = '?version=%s' % version if version else '' - return {'title': '%s' % config.DOMAIN[resource]['item_title'], - 'href': '%s/%s%s' % (resource_link(), document_id, version_part)} + version_part = "?version=%s" % version if version else "" + return { + "title": "%s" % config.DOMAIN[resource]["item_title"], + "href": "%s/%s%s" % (resource_link(resource), document_id, version_part), + } -def resource_link(): - """ Returns the current resource path relative to the API entry point. - Mostly going to be used by hatoeas functions when building +def resource_link(resource=None): + """Returns the current resource path relative to the API entry point. + Mostly going to be used by hateoas functions when building document/resource links. The resource URL stored in the config settings might contain regexes and custom variable names, all of which are not needed in the response payload. + :param resource: the resource name if not using the resource from request.path + + .. versionchanged:: 0.8.2 + Support resource link for data relation resources + which may be different from request.path resource. See #1204. + .. versionchanged:: 0.5 URL is relative to API root. .. versionadded:: 0.4 """ - path = request.path.strip('/') + path = request.path.strip("/") - if '|item' in request.endpoint: - path = path[:path.rfind('/')] + if request.endpoint and "|item" in request.endpoint: + path = path[: path.rfind("/")] def strip_prefix(hit): - return path[len(hit):] if path.startswith(hit) else path + return path[len(hit) :] if path.startswith(hit) else path if config.URL_PREFIX: - path = strip_prefix(config.URL_PREFIX + '/') + path = strip_prefix(config.URL_PREFIX + "/") if config.API_VERSION: - path = strip_prefix(config.API_VERSION + '/') + path = strip_prefix(config.API_VERSION + "/") + + # If request path does not match resource URL regex definition + # We are creating a path for data relation resources + if resource and not re.search(config.DOMAIN[resource]["url"], path): + return config.DOMAIN[resource]["url"] return path def oplog_push(resource, document, op, id=None): - """ Pushes an edit operation to the oplog if included in OPLOG_METHODS. To + """Pushes an edit operation to the oplog if included in OPLOG_METHODS. To save on storage space (at least on MongoDB) field names are shortened: 'r' = resource endpoint, 'o' = operation performed, 'i' = unique id of the document involved, - 'pi' = client IP, + 'ip' = client IP, 'c' = changes config.LAST_UPDATED, config.LAST_CREATED and AUTH_FIELD are not being @@ -1084,7 +1469,12 @@ def oplog_push(resource, document, op, id=None): .. versionadded:: 0.5 """ - if not config.OPLOG or op not in config.OPLOG_METHODS: + + if ( + not config.OPLOG + or op not in config.OPLOG_METHODS + or resource not in config.URLS + ): return resource_def = config.DOMAIN[resource] @@ -1100,30 +1490,32 @@ def oplog_push(resource, document, op, id=None): entries = [] for update in updates: entry = { - 'r': config.URLS[resource], - 'o': op, - 'i': (update[resource_def['id_field']] - if resource_def['id_field'] in update else id), + "r": config.URLS[resource], + "o": op, + "i": ( + update[resource_def["id_field"]] + if resource_def["id_field"] in update + else id + ), } if config.LAST_UPDATED in update: last_update = update[config.LAST_UPDATED] else: - last_update = datetime.utcnow().replace(microsecond=0) + last_update = utcnow() entry[config.LAST_UPDATED] = entry[config.DATE_CREATED] = last_update if config.OPLOG_AUDIT: - entry['ip'] = request.remote_addr + entry["ip"] = request.remote_addr - auth = resource_def['authentication'] - entry['u'] = auth.get_user_or_token() if auth else 'n/a' + auth = resource_def["authentication"] + entry["u"] = auth.get_user_or_token() if auth else "n/a" if op in config.OPLOG_CHANGE_METHODS: - # these fields are already contained in 'entry'. - del(update[config.LAST_UPDATED]) - # legacy documents (v0.4 or less) could be missing the etag - # field - if config.ETAG in update: - del(update[config.ETAG]) - entry['c'] = update + entry["c"] = { + key: value + for key, value in update.items() + # these fields are already contained in 'entry'. + if key not in [config.ETAG, config.LAST_UPDATED] + } else: pass @@ -1136,3 +1528,7 @@ def oplog_push(resource, document, op, id=None): getattr(app, "on_oplog_push")(resource, entries) # oplog push app.data.insert(config.OPLOG_NAME, entries) + + +def utcnow(): + return datetime.now(timezone.utc).replace(microsecond=0) diff --git a/eve/methods/delete.py b/eve/methods/delete.py index 268ea8f7e..a595d7d63 100644 --- a/eve/methods/delete.py +++ b/eve/methods/delete.py @@ -4,25 +4,31 @@ eve.methods.delete ~~~~~~~~~~~~~~~~~~ - This module imlements the DELETE method. + This module implements the DELETE method. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ -from flask import current_app as app, abort -from eve.utils import config, ParsedRequest -from eve.auth import requires_auth -from eve.methods.common import get_document, ratelimit, pre_event, \ - oplog_push, resolve_document_etag -from eve.versioning import versioned_id_field, resolve_document_version, \ - insert_versioning_documents, late_versioning_catch -from datetime import datetime import copy +from flask import abort +from flask import current_app as app + +from eve.auth import requires_auth +from eve.methods.common import (get_document, oplog_push, pre_event, ratelimit, + resolve_document_etag, utcnow) +from eve.utils import ParsedRequest, config +from eve.versioning import (insert_versioning_documents, late_versioning_catch, + resolve_document_version, versioned_id_field) + + +def all_done(): + return {}, None, None, 204 + @ratelimit() -@requires_auth('item') +@requires_auth("item") @pre_event def deleteitem(resource, **lookup): """ @@ -38,13 +44,15 @@ def deleteitem(resource, **lookup): def deleteitem_internal( - resource, concurrency_check=False, suppress_callbacks=False, **lookup): - """ Intended for internal delete calls, this method is not rate limited, + resource, concurrency_check=False, suppress_callbacks=False, original=None, **lookup +): + """Intended for internal delete calls, this method is not rate limited, authentication is not checked, pre-request events are not raised, and concurrency checking is optional. Deletes a resource item. :param resource: name of the resource to which the item(s) belong. :param concurrency_check: concurrency check switch (bool) + :param original: original document if already fetched from the database :param **lookup: item lookup query. .. versionchanged:: 0.6 @@ -82,14 +90,19 @@ def deleteitem_internal( Added the ``requires_auth`` decorator. """ resource_def = config.DOMAIN[resource] - soft_delete_enabled = resource_def['soft_delete'] - original = get_document(resource, concurrency_check, **lookup) - if not original or (soft_delete_enabled and - original.get(config.DELETED) is True): - abort(404) + soft_delete_enabled = resource_def["soft_delete"] + original = get_document( + resource, + concurrency_check, + original, + force_auth_field_projection=soft_delete_enabled, + **lookup + ) + if not original or (soft_delete_enabled and original.get(config.DELETED) is True): + return all_done() # notify callbacks - if suppress_callbacks is not True: + if not suppress_callbacks: getattr(app, "on_delete_item")(resource, original) getattr(app, "on_delete_item_%s" % resource)(original) @@ -98,77 +111,81 @@ def deleteitem_internal( marked_document = copy.deepcopy(original) # Set DELETED flag and update metadata - last_modified = datetime.utcnow().replace(microsecond=0) + last_modified = utcnow() marked_document[config.DELETED] = True marked_document[config.LAST_UPDATED] = last_modified if config.IF_MATCH: resolve_document_etag(marked_document, resource) - resolve_document_version(marked_document, resource, 'DELETE', original) + resolve_document_version(marked_document, resource, "DELETE", original) # Update document in database (including version collection if needed) - id = original[resource_def['id_field']] + id = original[resource_def["id_field"]] try: app.data.replace(resource, id, marked_document, original) except app.data.OriginalChangedError: if concurrency_check: - abort(412, description='Client and server etags don\'t match') + abort(412, description="Client and server etags don't match") # create previous version if it wasn't already there late_versioning_catch(original, resource) # and add deleted version insert_versioning_documents(resource, marked_document) # update oplog if needed - oplog_push(resource, marked_document, 'DELETE', id) + oplog_push(resource, marked_document, "DELETE", id) else: # Delete the document for real # media cleanup - media_fields = app.config['DOMAIN'][resource]['_media'] + media_fields = app.config["DOMAIN"][resource]["_media"] # document might miss one or more media fields because of datasource # and/or client projection. missing_media_fields = [f for f in media_fields if f not in original] - if len(missing_media_fields): + if missing_media_fields: # retrieve the whole document so we have all media fields available - # Should be very a rare occurence. We can't get rid of the + # Should be very a rare occurrence. We can't get rid of the # get_document() call since it also deals with etag matching, which # is still needed. Also, this lookup should never fail. # TODO not happy with this hack. Not at all. Is there a better way? - original = app.data.find_one_raw( - resource, original[resource_def['id_field']]) + original = app.data.find_one_raw(resource, **lookup) for field in media_fields: if field in original: - app.media.delete(original[field], resource) - - id = original[resource_def['id_field']] - app.data.remove(resource, {resource_def['id_field']: id}) + media_field = original[field] + if isinstance(media_field, list): + for file_id in media_field: + app.media.delete(file_id, resource) + else: + app.media.delete(original[field], resource) + + id = original[resource_def["id_field"]] + app.data.remove(resource, lookup) # TODO: should attempt to delete version collection even if setting is # off - if app.config['DOMAIN'][resource]['versioning'] is True: + if app.config["DOMAIN"][resource]["versioning"] is True: app.data.remove( resource + config.VERSIONS, - {versioned_id_field(resource_def): - original[resource_def['id_field']]}) + {versioned_id_field(resource_def): original[resource_def["id_field"]]}, + ) # update oplog if needed - oplog_push(resource, original, 'DELETE', id) + oplog_push(resource, original, "DELETE", id) - if suppress_callbacks is not True: + if not suppress_callbacks: getattr(app, "on_deleted_item")(resource, original) getattr(app, "on_deleted_item_%s" % resource)(original) - return {}, None, None, 204 + return all_done() -@requires_auth('resource') +@requires_auth("resource") @pre_event def delete(resource, **lookup): - """ Deletes all item of a resource (collection in MongoDB terms). Won't + """Deletes all item of a resource (collection in MongoDB terms). Won't drop indexes. Use with caution! .. versionchanged:: 0.5 @@ -180,7 +197,7 @@ def delete(resource, **lookup): 'on_deleted_resource' raised after performing the delete .. versionchanged:: 0.3 - Support for the lookup filter, which allows for develtion of + Support for the lookup filter, which allows for devolution of sub-resources (only delete documents that match a given condition). .. versionchanged:: 0.0.4 @@ -188,20 +205,40 @@ def delete(resource, **lookup): .. versionadded:: 0.0.2 """ - getattr(app, "on_delete_resource")(resource) - getattr(app, "on_delete_resource_%s" % resource)() resource_def = config.DOMAIN[resource] - - if resource_def['soft_delete']: - # Soft delete all items not already marked deleted - # (by default, data.find doesn't return soft deleted items) - default_request = ParsedRequest() - cursor = app.data.find(resource, default_request, lookup) - for document in list(cursor): - document_id = document[resource_def['id_field']] - deleteitem_internal(resource, concurrency_check=False, - suppress_callbacks=True, _id=document_id) + getattr(app, "on_delete_resource")(resource) + getattr(app, "on_delete_resource_%s" % resource)() + default_request = ParsedRequest() + if resource_def["soft_delete"]: + # get_document should always fetch soft deleted documents from the db + # callers must handle soft deleted documents + default_request.show_deleted = True + result, _ = app.data.find(resource, default_request, lookup) + originals = list(result) + if not originals: + return all_done() + # I add new callback as I want the framework to be retro-compatible + getattr(app, "on_delete_resource_originals")(resource, originals, lookup) + getattr(app, "on_delete_resource_originals_%s" % resource)(originals, lookup) + id_field = resource_def["id_field"] + + if resource_def["soft_delete"]: + # I need to check that I have at least some documents not soft_deleted + # I skip all the soft_deleted documents + originals = [x for x in originals if not x.get(config.DELETED)] + if not originals: + # Nothing to be deleted + return all_done() + for document in originals: + lookup[id_field] = document[id_field] + deleteitem_internal( + resource, + concurrency_check=False, + suppress_callbacks=True, + original=document, + **lookup + ) else: # TODO if the resource schema includes media files, these won't be # deleted by use of this global method (it should be disabled). Media @@ -211,10 +248,10 @@ def delete(resource, **lookup): # TODO: should attempt to delete version collection even if setting is # off - if resource_def['versioning'] is True: + if resource_def["versioning"] is True: app.data.remove(resource + config.VERSIONS, lookup) getattr(app, "on_deleted_resource")(resource) getattr(app, "on_deleted_resource_%s" % resource)() - return {}, None, None, 204 + return all_done() diff --git a/eve/methods/get.py b/eve/methods/get.py index 8ab242a84..c9906ca4f 100644 --- a/eve/methods/get.py +++ b/eve/methods/get.py @@ -7,26 +7,32 @@ This module implements the API 'GET' methods, supported by both the resources and single item endpoints. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ -import math +from __future__ import division import copy -import json -from flask import current_app as app, abort, request -from werkzeug import MultiDict +import math + +import simplejson as json +from flask import abort +from flask import current_app as app +from flask import request +from werkzeug.datastructures import MultiDict -from .common import ratelimit, epoch, pre_event, resolve_embedded_fields, \ - build_response_document, resource_link, document_link, last_updated from eve.auth import requires_auth -from eve.utils import parse_request, home_link, querydef, config -from eve.versioning import synthesize_versioned_document, versioned_id_field, \ - get_old_document, diff_document +from eve.utils import config, home_link, parse_request, querydef +from eve.versioning import (diff_document, get_old_document, + synthesize_versioned_document, versioned_id_field) + +from .common import (build_response_document, document_link, epoch, + last_updated, pre_event, ratelimit, + resolve_embedded_fields, resource_link) @ratelimit() -@requires_auth('resource') +@requires_auth("resource") @pre_event def get(resource, **lookup): """ @@ -40,7 +46,7 @@ def get(resource, **lookup): def get_internal(resource, **lookup): - """ Retrieves the resource documents that match the current request. + """Retrieves the resource documents that match the current request. :param resource: the name of the resource. @@ -48,7 +54,7 @@ def get_internal(resource, **lookup): Support for HEADER_TOTAL_COUNT returned with response header. .. versionchanged:: 0.5 - Support for customisable query parameters. + Support for customizable query parameters. .. versionchanged:: 0.4 Add pagination info whatever the HATEOAS status. @@ -77,7 +83,7 @@ def get_internal(resource, **lookup): Support for embeddable documents. .. versionchanged:: 0.0.9 - Event hooks renamed to be more robuts and consistent: 'on_getting' + Event hooks renamed to be more robust and consistent: 'on_getting' renamed to 'on_fetch'. .. versionchanged:: 0.0.8 @@ -102,31 +108,61 @@ def get_internal(resource, **lookup): JSON formatted. """ - datasource = config.DOMAIN[resource]['datasource'] - aggregation = datasource.get('aggregation') + datasource = config.DOMAIN[resource]["datasource"] + aggregation = datasource.get("aggregation") if aggregation: - return _perform_aggregation(resource, aggregation['pipeline'], - aggregation['options']) - else: - return _perform_find(resource, lookup) + return _perform_aggregation( + resource, aggregation["pipeline"], aggregation["options"] + ) + return _perform_find(resource, lookup) def _perform_aggregation(resource, pipeline, options): """ .. versionadded:: 0.7 """ + # TODO move most of this down to the Mongo layer? # TODO experiment with cursor.batch_size as alternative pagination # implementation - def parse_aggregation_stage(d, key, value): - for st_key, st_value in d.items(): - if isinstance(st_value, dict): + def parse_aggregation_stage(st_item, key, value): + def parse_again(st_value, key, value): + """ + If stage value is list or dict then parse recursively + """ + if isinstance(st_value, (list, dict)): parse_aggregation_stage(st_value, key, value) - if key == st_value: - d[st_key] = value + + if isinstance(st_item, dict): + for st_key, st_value in st_item.items(): + if key == st_value: + st_item[st_key] = value + else: + parse_again(st_value, key, value) + + elif isinstance(st_item, list): + for st_i, st_value in enumerate(st_item): + if key == st_value: + st_item[st_i] = value + else: + parse_again(st_value, key, value) + + def prune_aggregation_stage(d): + """ + Remove the stages whose parameters are not set. + + For example, we have endpoint with a stage like {'$lookup': {'$userId': '$a', '$name': '$b'}}, $a is provided + but $b is provided as {}. Then the stage will be pruned as {'$lookup': {'$userId': '$a'}} + """ + for st_key, st_value in list(d.items()): + if isinstance(st_value, dict): + prune_aggregation_stage(st_value) + if not st_value: + # value is an empty dict, remove the key + del d[st_key] response = {} documents = [] @@ -137,30 +173,67 @@ def parse_aggregation_stage(d, key, value): try: query = json.loads(req.aggregation) except ValueError: - abort(400, description='Aggregation query could not be parsed.') + abort(400, description="Aggregation query could not be parsed.") for key, value in query.items(): - if key[0] != '$': + if key.startswith("$"): pass for stage in req_pipeline: parse_aggregation_stage(stage, key, value) - if req.max_results > 1: + # remove the stages whose conditions are not yet set + req_pipeline_pruned = [] + for stage in req_pipeline: + prune_aggregation_stage(stage) + if stage: + req_pipeline_pruned.append(stage) + + paginated_results = [] + + if req.max_results > 0: limit = {"$limit": req.max_results} skip = {"$skip": (req.page - 1) * req.max_results} - req_pipeline.append(skip) - req_pipeline.append(limit) + paginated_results.append(skip) + paginated_results.append(limit) + else: + # sub-pipeline in $facet stage cannot be empty + skip = {"$skip": 0} + paginated_results.append(skip) - cursor = app.data.aggregate(resource, req_pipeline, options) + facet_pipelines = {} + facet_pipelines["paginated_results"] = paginated_results + facet_pipelines["total_count"] = [{"$count": "count"}] - for document in cursor: + facet = {"$facet": facet_pipelines} + + getattr(app, "before_aggregation")(resource, req_pipeline_pruned) + + # Appending $facet afer the before_aggregation hook allows for + # easy modification of the orginal pipline, however, pagination + # (skip, limit) cannot be accessed. + req_pipeline_pruned.append(facet) + + cursor = app.data.aggregate(resource, req_pipeline_pruned, options).next() + + for document in cursor["paginated_results"]: documents.append(document) + getattr(app, "after_aggregation")(resource, documents) + response[config.ITEMS] = documents - # PyMongo's CommandCursor does not return a count, so we cannot - # provide paination/total count info as we do with a normal (non-aggregate) - # GET request. + if cursor["total_count"]: + # IndexError: list index out of range + count = cursor["total_count"][0]["count"] + else: + count = 0 + + # add pagination info + if config.DOMAIN[resource]["pagination"]: + response[config.META] = _meta_links(resource, req, count) + + if config.DOMAIN[resource]["hateoas"]: + response[config.LINKS] = _pagination_links(resource, req, count) return response, None, None, 200, [] @@ -181,7 +254,9 @@ def _perform_find(resource, lookup): # If-Modified-Since disabled on collections (#334) req.if_modified_since = None - cursor = app.data.find(resource, req, lookup) + cursor, count = app.data.find( + resource, req, lookup, perform_count=not config.DOMAIN[resource]["optimize_pagination_for_speed"] + ) # If soft delete is enabled, data.find will not include items marked # deleted unless req.show_deleted is True for document in cursor: @@ -197,15 +272,16 @@ def _perform_find(resource, lookup): last_modified = last_update if last_update > epoch() else None response[config.ITEMS] = documents - count = cursor.count(with_limit_and_skip=False) - headers.append((config.HEADER_TOTAL_COUNT, count)) - if config.DOMAIN[resource]['hateoas']: + if count is not None: + headers.append((config.HEADER_TOTAL_COUNT, count)) + + if config.DOMAIN[resource]["hateoas"]: response[config.LINKS] = _pagination_links(resource, req, count) # add pagination info - if config.DOMAIN[resource]['pagination']: - response[config.META] = _meta_links(req, count) + if config.DOMAIN[resource]["pagination"]: + response[config.META] = _meta_links(resource, req, count) # notify registered callback functions. Please note that, should the # functions modify the documents, the last_modified and etag won't be @@ -217,14 +293,14 @@ def _perform_find(resource, lookup): # the 'extra' cursor field, if present, will be added to the response. # Can be used by Eve extensions to add extra, custom data to any # response. - if hasattr(cursor, 'extra'): - getattr(cursor, 'extra')(response) + if hasattr(cursor, "extra"): + getattr(cursor, "extra")(response) return response, last_modified, etag, status, headers @ratelimit() -@requires_auth('item') +@requires_auth("item") @pre_event def getitem(resource, **lookup): """ @@ -243,6 +319,10 @@ def getitem_internal(resource, **lookup): :param resource: the name of the resource to which the document belongs. :param **lookup: the lookup query. + .. versionchanged:: 0.8.2 + Prevent extra hateoas links from overwriting + already existed data relation hateoas links. + .. versionchanged:: 0.6 Handle soft deleted documents @@ -252,7 +332,7 @@ def getitem_internal(resource, **lookup): Pagination links reflect current query. (#464) .. versionchanged:: 0.4 - HATOEAS link for contains the business unit value even when + HATEOAS link for contains the business unit value even when regexes have been configured for the resource endpoint. 'on_fetched' now returns the whole response (HATEOAS metafields included.) @@ -264,7 +344,7 @@ def getitem_internal(resource, **lookup): When IF_MATCH is disabled, no etag is included in the payload. .. versionchanged:: 0.1.1 - Support for Embeded Resource Serialization. + Support for Embedded Resource Serialization. .. versionchanged:: 0.1.0 Support for optional HATEOAS. @@ -298,7 +378,7 @@ def getitem_internal(resource, **lookup): resource_def = config.DOMAIN[resource] embedded_fields = resolve_embedded_fields(resource, req) - soft_delete_enabled = config.DOMAIN[resource]['soft_delete'] + soft_delete_enabled = config.DOMAIN[resource]["soft_delete"] if soft_delete_enabled: # GET requests should always fetch soft deleted documents from the db # They are handled and included in 404 responses below. @@ -319,16 +399,15 @@ def getitem_internal(resource, **lookup): last_modified = last_updated(document) # synthesize old document version(s) - if resource_def['versioning'] is True: + if resource_def["versioning"] is True: latest_doc = document - document = get_old_document( - resource, req, lookup, document, version) + document = get_old_document(resource, req, lookup, document, version) # meld into response document build_response_document(document, resource, embedded_fields, latest_doc) if config.IF_MATCH: etag = document[config.ETAG] - if resource_def['versioning'] is True: + if resource_def["versioning"] is True: # In order to keep the LATEST_VERSION field up to date in client # caches, changes to the latest version should invalidate cached # copies of previous verisons. Incorporate the latest version into @@ -349,29 +428,28 @@ def getitem_internal(resource, **lookup): # facilitate client caching by returning a 304 when appropriate cache_validators = {True: 0, False: 0} if req.if_modified_since: - cache_valid = (last_modified <= req.if_modified_since) + cache_valid = last_modified <= req.if_modified_since cache_validators[cache_valid] += 1 if req.if_none_match: - cache_valid = (etag == req.if_none_match) + cache_valid = etag == req.if_none_match cache_validators[cache_valid] += 1 # If all cache validators are true, return 304 if (cache_validators[True] > 0) and (cache_validators[False] == 0): return {}, last_modified, etag, 304 - if version == 'all' or version == 'diffs': + if version in ("all", "diffs"): # find all versions - lookup[versioned_id_field(resource_def)] \ - = lookup[resource_def['id_field']] - del lookup[resource_def['id_field']] - if version == 'diffs' or req.sort is None: + lookup[versioned_id_field(resource_def)] = lookup[resource_def["id_field"]] + del lookup[resource_def["id_field"]] + if version == "diffs" or req.sort is None: # default sort for 'all', required sort for 'diffs' req.sort = '[("%s", 1)]' % config.VERSION req.if_modified_since = None # we always want the full history here - cursor = app.data.find(resource + config.VERSIONS, req, lookup) + cursor, count = app.data.find(resource + config.VERSIONS, req, lookup) # build all versions documents = [] - if cursor.count() == 0: + if count == 0: # this is the scenario when the document existed before # document versioning got turned on documents.append(latest_doc) @@ -379,83 +457,90 @@ def getitem_internal(resource, **lookup): last_document = {} # if we aren't starting on page 1, then we need to init last_doc - if version == 'diffs' and req.page > 1: + if version == "diffs" and req.page > 1: # grab the last document on the previous page to diff from - last_version = cursor[0][app.config['VERSION']] - 1 + last_version = cursor[0][app.config["VERSION"]] - 1 last_document = get_old_document( - resource, req, lookup, latest_doc, last_version) + resource, req, lookup, latest_doc, last_version + ) for i, document in enumerate(cursor): document = synthesize_versioned_document( - latest_doc, document, resource_def) - build_response_document( - document, resource, embedded_fields, latest_doc) - if version == 'diffs': + latest_doc, document, resource_def + ) + build_response_document(document, resource, embedded_fields, latest_doc) + if version == "diffs": if i == 0: documents.append(document) else: - documents.append(diff_document( - resource_def, last_document, document)) + documents.append( + diff_document(resource_def, last_document, document) + ) last_document = document else: documents.append(document) # add documents to response - if config.DOMAIN[resource]['hateoas']: + if config.DOMAIN[resource]["hateoas"]: response[config.ITEMS] = documents else: response = documents elif soft_delete_enabled and document.get(config.DELETED) is True: # This document was soft deleted. Respond with 404 and the deleted # version of the document. - document[config.STATUS] = config.STATUS_ERR, + document[config.STATUS] = (config.STATUS_ERR,) document[config.ERROR] = { - 'code': 404, - 'message': 'The requested URL was not found on this server.' + "code": 404, + "message": "The requested URL was not found on this server.", } return document, last_modified, etag, 404 else: response = document # extra hateoas links - if config.DOMAIN[resource]['hateoas']: + if config.DOMAIN[resource]["hateoas"]: # use the id of the latest document for multi-document requests if cursor: - count = cursor.count(with_limit_and_skip=False) - response[config.LINKS] = \ - _pagination_links(resource, req, count, - latest_doc[resource_def['id_field']]) - if config.DOMAIN[resource]['pagination']: - response[config.META] = _meta_links(req, count) + response[config.LINKS] = _pagination_links( + resource, req, count, latest_doc[resource_def["id_field"]] + ) + if config.DOMAIN[resource]["pagination"]: + response[config.META] = _meta_links(resource, req, count) + else: + response[config.LINKS].update( + _pagination_links( + resource, req, None, response[resource_def["id_field"]] + ) + ) + + # callbacks supported on all version methods - even for diffs with partial documents + # partial documents should be handled properly in the callback + # + # notify registered callback functions. Please note that, should + # the functions modify the document, last_modified and etag + # won't be updated to reflect the changes (they always reflect the + # documents state on the database). + if resource_def["versioning"] is True and version in ("all", "diffs"): + versions = response + if config.DOMAIN[resource]["hateoas"]: + versions = response[config.ITEMS] + + if version == "diffs": + getattr(app, "on_fetched_diffs")(resource, versions) + getattr(app, "on_fetched_diffs_%s" % resource)(versions) else: - response[config.LINKS] = \ - _pagination_links(resource, req, None, - response[resource_def['id_field']]) - - # callbacks not supported on version diffs because of partial documents - if version != 'diffs': - # TODO: callbacks not currently supported with ?version=all - - # notify registered callback functions. Please note that, should - # the functions modify the document, last_modified and etag - # won't be updated to reflect the changes (they always reflect the - # documents state on the database). - if resource_def['versioning'] is True and version == 'all': - versions = response - if config.DOMAIN[resource]['hateoas']: - versions = response[config.ITEMS] for version_item in versions: getattr(app, "on_fetched_item")(resource, version_item) getattr(app, "on_fetched_item_%s" % resource)(version_item) - else: - getattr(app, "on_fetched_item")(resource, response) - getattr(app, "on_fetched_item_%s" % resource)(response) + else: + getattr(app, "on_fetched_item")(resource, response) + getattr(app, "on_fetched_item_%s" % resource)(response) return response, last_modified, etag, 200 -def _pagination_links(resource, req, documents_count, document_id=None): - """ Returns the appropriate set of resource links depending on the +def _pagination_links(resource, req, document_count, document_id=None): + """Returns the appropriate set of resource links depending on the current page and the total number of documents returned by the query. :param resource: the resource name. @@ -469,7 +554,7 @@ def _pagination_links(resource, req, documents_count, document_id=None): Pagination links reflect current query. (#464) .. versionchanged:: 0.4 - HATOEAS link for contains the business unit value even when + HATEOAS link for contains the business unit value even when regexes have been configured for the resource endpoint. .. versionchanged:: 0.0.8 @@ -486,94 +571,133 @@ def _pagination_links(resource, req, documents_count, document_id=None): JSON links """ version = None - if config.DOMAIN[resource]['versioning'] is True: + if config.DOMAIN[resource]["versioning"] is True: version = request.args.get(config.VERSION_PARAM) other_params = _other_params(req.args) # construct the default links - q = querydef(req.max_results, req.where, req.sort, version, req.page, - other_params) - resource_title = config.DOMAIN[resource]['resource_title'] - _links = {'parent': home_link(), - 'self': {'title': resource_title, - 'href': resource_link()}} + q = querydef(req.max_results, req.where, req.sort, version, req.page, other_params) + resource_title = config.DOMAIN[resource]["resource_title"] + _links = { + "parent": home_link(), + "self": {"title": resource_title, "href": resource_link()}, + } # change links if document ID is given if document_id: - _links['self'] = document_link(resource, document_id) - _links['collection'] = {'title': resource_title, - 'href': '%s%s' % (resource_link(), q)} + _links["self"] = document_link(resource, document_id) + _links["collection"] = { + "title": resource_title, + "href": "%s%s" % (resource_link(), q), + } # make more specific links for versioned requests - if version in ('all', 'diffs'): - _links['parent'] = {'title': resource_title, - 'href': resource_link()} - _links['collection'] = document_link(resource, document_id) + if version in ("all", "diffs"): + _links["parent"] = {"title": resource_title, "href": resource_link()} + _links["collection"] = document_link(resource, document_id) elif version: - _links['parent'] = document_link(resource, document_id) - _links['collection'] = {'title': resource_title, - 'href': '%s?version=all' - % _links['parent']['href']} + _links["parent"] = document_link(resource, document_id) + _links["collection"] = { + "title": resource_title, + "href": "%s?version=all" % _links["parent"]["href"], + } # modify the self link to add query params or version number - if documents_count: - _links['self']['href'] = '%s%s' % (_links['self']['href'], q) - elif not documents_count and version and version not in ('all', 'diffs'): - _links['self'] = document_link(resource, document_id, version) + if document_count: + _links["self"]["href"] = "%s%s" % (_links["self"]["href"], q) + elif not document_count and version and version not in ("all", "diffs"): + _links["self"] = document_link(resource, document_id, version) # create pagination links - if documents_count and config.DOMAIN[resource]['pagination']: - # strip any queries from the self link if present - _pagination_link = _links['self']['href'].split('?')[0] - if req.page * req.max_results < documents_count: - q = querydef(req.max_results, req.where, req.sort, version, - req.page + 1, other_params) - _links['next'] = {'title': 'next page', 'href': '%s%s' % - (_pagination_link, q)} - - # in python 2.x dividing 2 ints produces an int and that's rounded - # before the ceil call. Have to cast one value to float to get - # a correct result. Wonder if 2 casts + ceil() call are actually - # faster than documents_count // req.max_results and then adding - # 1 if the modulo is non-zero... - last_page = int(math.ceil(documents_count / - float(req.max_results))) - q = querydef(req.max_results, req.where, req.sort, version, - last_page, other_params) - _links['last'] = {'title': 'last page', 'href': '%s%s' - % (_pagination_link, q)} + if config.DOMAIN[resource]["pagination"]: + # For version pagination (all/diffs), use the document self link. + # Otherwise, use the resource (collection) link so that item + # endpoints don't include a document ID in the next/prev/last hrefs. + if document_id and version not in ("all", "diffs"): + _pagination_link = resource_link() + else: + _pagination_link = _links["self"]["href"].split("?")[0] + + if ( + req.page * req.max_results < (document_count or 0) + or config.DOMAIN[resource]["optimize_pagination_for_speed"] + ): + q = querydef( + req.max_results, + req.where, + req.sort, + version, + req.page + 1, + other_params, + ) + _links["next"] = { + "title": "next page", + "href": "%s%s" % (_pagination_link, q), + } + + if document_count: + last_page = int(math.ceil(document_count / req.max_results)) + q = querydef( + req.max_results, + req.where, + req.sort, + version, + last_page, + other_params, + ) + _links["last"] = { + "title": "last page", + "href": "%s%s" % (_pagination_link, q), + } if req.page > 1: - q = querydef(req.max_results, req.where, req.sort, version, - req.page - 1, other_params) - _links['prev'] = {'title': 'previous page', 'href': '%s%s' % - (_pagination_link, q)} + q = querydef( + req.max_results, + req.where, + req.sort, + version, + req.page - 1, + other_params, + ) + _links["prev"] = { + "title": "previous page", + "href": "%s%s" % (_pagination_link, q), + } return _links def _other_params(args): - """ Returns a multidict of params that are not used internally by Eve. + """Returns a multidict of params that are not used internally by Eve. :param args: multidict containing the request parameters """ - default_params = [config.QUERY_WHERE, config.QUERY_SORT, - config.QUERY_PAGE, config.QUERY_MAX_RESULTS, - config.QUERY_EMBEDDED, config.QUERY_PROJECTION] - return MultiDict((key, value) for key, values in args.lists() - for value in values if key not in default_params) - - -def _meta_links(req, count): - """ Reterns the meta links for a paginated query. + default_params = [ + config.QUERY_WHERE, + config.QUERY_SORT, + config.QUERY_PAGE, + config.QUERY_MAX_RESULTS, + config.QUERY_EMBEDDED, + config.QUERY_PROJECTION, + config.VERSION_PARAM, + ] + return MultiDict( + (key, value) + for key, values in args.lists() + for value in values + if key not in default_params + ) + + +def _meta_links(resource, req, count): + """Reterns the meta links for a paginated query. :param req: parsed request object. :param count: total number of documents in a query. .. versionadded:: 0.5 """ - return { - config.QUERY_PAGE: req.page, - config.QUERY_MAX_RESULTS: req.max_results, - 'total': count - } + meta = {config.QUERY_PAGE: req.page, config.QUERY_MAX_RESULTS: req.max_results} + if config.DOMAIN[resource]["optimize_pagination_for_speed"] is False: + meta["total"] = count + return meta diff --git a/eve/methods/patch.py b/eve/methods/patch.py index 406ca0a8e..9b3ae1c8e 100644 --- a/eve/methods/patch.py +++ b/eve/methods/patch.py @@ -4,29 +4,33 @@ eve.methods.patch ~~~~~~~~~~~~~~~~~ - This module imlements the PATCH method. + This module implements the PATCH method. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ from copy import deepcopy -from flask import current_app as app, abort + +from cerberus.validator import DocumentError +from flask import abort +from flask import current_app as app from werkzeug import exceptions -from datetime import datetime -from eve.utils import config, debug_error_message, parse_request + from eve.auth import requires_auth -from eve.validation import ValidationError -from eve.methods.common import get_document, parse, payload as payload_, \ - ratelimit, pre_event, store_media_files, resolve_embedded_fields, \ - build_response_document, marshal_write_response, resolve_document_etag, \ - oplog_push -from eve.versioning import resolve_document_version, \ - insert_versioning_documents, late_versioning_catch +from eve.methods.common import (build_response_document, get_document, + marshal_write_response, oplog_push, parse) +from eve.methods.common import payload as payload_ +from eve.methods.common import (pre_event, ratelimit, resolve_document_etag, + resolve_embedded_fields, store_media_files, + utcnow) +from eve.utils import config, debug_error_message, parse_request +from eve.versioning import (insert_versioning_documents, late_versioning_catch, + resolve_document_version) @ratelimit() -@requires_auth('item') +@requires_auth("item") @pre_event def patch(resource, payload=None, **lookup): """ @@ -37,13 +41,20 @@ def patch(resource, payload=None, **lookup): .. versionchanged:: 0.5 Split into patch() and patch_internal(). """ - return patch_internal(resource, payload, concurrency_check=True, - skip_validation=False, **lookup) - - -def patch_internal(resource, payload=None, concurrency_check=False, - skip_validation=False, **lookup): - """ Intended for internal patch calls, this method is not rate limited, + return patch_internal( + resource, payload, concurrency_check=True, skip_validation=False, **lookup + ) + + +def patch_internal( + resource, + payload=None, + concurrency_check=False, + skip_validation=False, + mongo_options=None, + **lookup +): + """Intended for internal patch calls, this method is not rate limited, authentication is not checked, pre-request events are not raised, and concurrency checking is optional. Performs a document patch/update. Updates are first validated against the resource schema. If validation @@ -61,6 +72,7 @@ def patch_internal(resource, payload=None, concurrency_check=False, option, a request context must be available. :param concurrency_check: concurrency check switch (bool) :param skip_validation: skip payload validation before write (bool) + :param mongo_options: options to pass to PyMongo. e.g. read_preferences of the initial get. :param **lookup: document lookup query. .. versionchanged:: 0.6.2 @@ -82,7 +94,7 @@ def patch_internal(resource, payload=None, concurrency_check=False, through. Fixes #395. .. versionchanged:: 0.4 - Allow abort() to be inoked by callback functions. + Allow abort() to be invoked by callback functions. 'on_update' raised before performing the update on the database. Support for document versioning. 'on_updated' raised after performing the update on the database. @@ -120,7 +132,7 @@ def patch_internal(resource, payload=None, concurrency_check=False, ETag is now computed without the need of an additional db lookup .. versionchanged:: 0.0.5 - Support for 'aplication/json' Content-Type. + Support for 'application/json' Content-Type. .. versionchanged:: 0.0.4 Added the ``requires_auth`` decorator. @@ -131,16 +143,21 @@ def patch_internal(resource, payload=None, concurrency_check=False, if payload is None: payload = payload_() - original = get_document(resource, concurrency_check, **lookup) + original = get_document( + resource, concurrency_check, mongo_options=mongo_options, **lookup + ) if not original: # not found abort(404) - resource_def = app.config['DOMAIN'][resource] - schema = resource_def['schema'] - validator = app.validator(schema, resource) + resource_def = app.config["DOMAIN"][resource] + schema = resource_def["schema"] + normalize_document = resource_def.get("normalize_on_patch") + validator = app.validator( + schema, resource=resource, allow_unknown=resource_def["allow_unknown"] + ) - object_id = original[resource_def['id_field']] + object_id = original[resource_def["id_field"]] last_modified = None etag = None @@ -158,8 +175,9 @@ def patch_internal(resource, payload=None, concurrency_check=False, if skip_validation: validation = True else: - validation = validator.validate_update(updates, object_id, - original) + validation = validator.validate_update( + updates, object_id, original, normalize_document + ) updates = validator.document if validation: @@ -169,13 +187,12 @@ def patch_internal(resource, payload=None, concurrency_check=False, late_versioning_catch(original, resource) store_media_files(updates, resource, original) - resolve_document_version(updates, resource, 'PATCH', original) + resolve_document_version(updates, resource, "PATCH", original) # some datetime precision magic - updates[config.LAST_UPDATED] = \ - datetime.utcnow().replace(microsecond=0) + updates[config.LAST_UPDATED] = utcnow() - if resource_def['soft_delete'] is True: + if resource_def["soft_delete"] is True: # PATCH with soft delete enabled should always set the DELETED # field to False. We are either carrying through un-deleted # status, or restoring a soft deleted document @@ -193,19 +210,23 @@ def patch_internal(resource, payload=None, concurrency_check=False, getattr(app, "on_update")(resource, updates, original) getattr(app, "on_update_%s" % resource)(updates, original) - updates = resolve_nested_documents(updates, updated) + if resource_def["merge_nested_documents"]: + updates = resolve_nested_documents(updates, updated) + updated.update(updates) if config.IF_MATCH: resolve_document_etag(updated, resource) # now storing the (updated) ETAG with every document (#453) updates[config.ETAG] = updated[config.ETAG] - - app.data.update( - resource, object_id, updates, original) + try: + app.data.update(resource, object_id, updates, original) + except app.data.OriginalChangedError: + if concurrency_check: + abort(412, description="Client and server etags don't match") # update oplog if needed - oplog_push(resource, updates, 'PATCH', object_id) + oplog_push(resource, updates, "PATCH", object_id) insert_versioning_documents(resource, updated) @@ -216,25 +237,22 @@ def patch_internal(resource, payload=None, concurrency_check=False, updated.update(updates) # build the full response document - build_response_document( - updated, resource, embedded_fields, updated) + build_response_document(updated, resource, embedded_fields, updated) response = updated if config.IF_MATCH: etag = response[config.ETAG] else: issues = validator.errors - except ValidationError as e: + except DocumentError as e: # TODO should probably log the error and abort 400 instead (when we # got logging) - issues['validator exception'] = str(e) + issues["validator exception"] = str(e) except exceptions.HTTPException as e: raise e except Exception as e: # consider all other exceptions as Bad Requests app.logger.exception(e) - abort(400, description=debug_error_message( - 'An exception occurred: %s' % e - )) + abort(400, description=debug_error_message("An exception occurred: %s" % e)) if len(issues): response[config.ISSUES] = issues @@ -251,7 +269,7 @@ def patch_internal(resource, payload=None, concurrency_check=False, def resolve_nested_documents(updates, original): - """ Nested document updates are merged with the original contents + """Nested document updates are merged with the original contents we don't overwrite the whole thing. See #519 for details. .. versionadded:: 0.5 diff --git a/eve/methods/post.py b/eve/methods/post.py index 7e2503299..3a6869767 100644 --- a/eve/methods/post.py +++ b/eve/methods/post.py @@ -4,29 +4,32 @@ eve.methods.post ~~~~~~~~~~~~~~~~ - This module imlements the POST method, supported by the resources - endopints. + This module implements the POST method, supported by the resources + endpoints. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ -from datetime import datetime -from flask import current_app as app, abort -from eve.utils import config, parse_request, debug_error_message +from cerberus.validator import DocumentError +from flask import abort +from flask import current_app as app + from eve.auth import requires_auth -from eve.defaults import resolve_default_values -from eve.validation import ValidationError -from eve.methods.common import parse, payload, ratelimit, \ - pre_event, store_media_files, resolve_user_restricted_access, \ - resolve_embedded_fields, build_response_document, marshal_write_response, \ - resolve_sub_resource_path, resolve_document_etag, oplog_push -from eve.versioning import resolve_document_version, \ - insert_versioning_documents +from eve.methods.common import (build_response_document, + marshal_write_response, oplog_push, parse, + payload, pre_event, ratelimit, + resolve_document_etag, resolve_embedded_fields, + resolve_sub_resource_path, + resolve_user_restricted_access, resource_link, + store_media_files, utcnow) +from eve.utils import config, debug_error_message, parse_request +from eve.versioning import (insert_versioning_documents, + resolve_document_version) @ratelimit() -@requires_auth('resource') +@requires_auth("resource") @pre_event def post(resource, payl=None): """ @@ -60,10 +63,13 @@ def post_internal(resource, payl=None, skip_validation=False): Please be advised that in order to successfully use this option, a request context must be available. - See https://github.com/nicolaiarocci/eve/issues/74 for a + See https://github.com/pyeve/eve/issues/74 for a discussion, and a typical use case. :param skip_validation: skip payload validation before write (bool) + .. versionchanged:: 0.7 + Add support for Location header. Closes #795. + .. versionchanged:: 0.6 Fix: since v0.6, skip_validation = True causes a 422 response (#726). @@ -71,7 +77,7 @@ def post_internal(resource, payl=None, skip_validation=False): Initialize DELETED field when soft_delete is enabled. .. versionchanged:: 0.5 - Back to resolving default values after validaton as now the validator + Back to resolving default values after validation as now the validator can properly validate dependency even when some have default values. See #353. Push updates to the OpLog. @@ -93,7 +99,7 @@ def post_internal(resource, payl=None, skip_validation=False): Use the new STATUS setting. Use the new ISSUES setting. Raise 'on_pre_' event. - Explictly resolve default values instead of letting them be resolved + Explicitly resolve default values instead of letting them be resolved by common.parse. This avoids a validation error when a read-only field also has a default value. Added ``on_inserted*`` events after the database insert @@ -106,7 +112,7 @@ def post_internal(resource, payl=None, skip_validation=False): Support for optional HATEOAS. .. versionchanged: 0.0.9 - Event hooks renamed to be more robuts and consistent: 'on_posting' + Event hooks renamed to be more robust and consistent: 'on_posting' renamed to 'on_insert'. You can now pass a pre-defined custom payload to the funcion. @@ -145,13 +151,21 @@ def post_internal(resource, payl=None, skip_validation=False): JSON links. Superflous ``response`` container removed. """ - date_utc = datetime.utcnow().replace(microsecond=0) - resource_def = app.config['DOMAIN'][resource] - schema = resource_def['schema'] - validator = None if skip_validation else app.validator(schema, resource) + date_utc = utcnow() + resource_def = app.config["DOMAIN"][resource] + schema = resource_def["schema"] + validator = ( + None + if skip_validation + else app.validator( + schema, resource=resource, allow_unknown=resource_def["allow_unknown"] + ) + ) + documents = [] results = [] failures = 0 + id_field = resource_def["id_field"] if config.BANDWIDTH_SAVER is True: embedded_fields = [] @@ -167,15 +181,11 @@ def post_internal(resource, payl=None, skip_validation=False): payl = [payl] if not payl: - # empty bulkd insert - abort(400, description=debug_error_message( - 'Empty bulk insert' - )) + # empty bulk insert + abort(400, description=debug_error_message("Empty bulk insert")) - if len(payl) > 1 and not config.DOMAIN[resource]['bulk_enabled']: - abort(400, description=debug_error_message( - 'Bulk insert not allowed' - )) + if len(payl) > 1 and not config.DOMAIN[resource]["bulk_enabled"]: + abort(400, description=debug_error_message("Bulk insert not allowed")) for value in payl: document = [] @@ -194,32 +204,27 @@ def post_internal(resource, payl=None, skip_validation=False): document = validator.document # Populate meta and default fields - document[config.LAST_UPDATED] = \ - document[config.DATE_CREATED] = date_utc + document[config.LAST_UPDATED] = document[config.DATE_CREATED] = date_utc - if config.DOMAIN[resource]['soft_delete'] is True: + if config.DOMAIN[resource]["soft_delete"] is True: document[config.DELETED] = False resolve_user_restricted_access(document, resource) - resolve_default_values(document, resource_def['defaults']) store_media_files(document, resource) - resolve_document_version(document, resource, 'POST') + resolve_document_version(document, resource, "POST") else: # validation errors added to list of document issues doc_issues = validator.errors - except ValidationError as e: - doc_issues['validation exception'] = str(e) + except DocumentError as e: + doc_issues["validation exception"] = str(e) except Exception as e: # most likely a problem with the incoming payload, report back to # the client as if it was a validation issue app.logger.exception(e) - doc_issues['exception'] = str(e) + doc_issues["exception"] = str(e) - if len(doc_issues): - document = { - config.STATUS: config.STATUS_ERR, - config.ISSUES: doc_issues, - } + if doc_issues: + document = {config.STATUS: config.STATUS_ERR, config.ISSUES: doc_issues} failures += 1 documents.append(document) @@ -228,8 +233,10 @@ def post_internal(resource, payl=None, skip_validation=False): # If at least one document got issues, the whole request fails and a # ``422 Bad Request`` status is return. for document in documents: - if config.STATUS in document \ - and document[config.STATUS] == config.STATUS_ERR: + if ( + config.STATUS in document + and document[config.STATUS] == config.STATUS_ERR + ): results.append(document) else: results.append({config.STATUS: config.STATUS_OK}) @@ -247,19 +254,18 @@ def post_internal(resource, payl=None, skip_validation=False): ids = app.data.insert(resource, documents) # update oplog if needed - oplog_push(resource, documents, 'POST') + oplog_push(resource, documents, "POST") # assign document ids for document in documents: # either return the custom ID_FIELD or the id returned by # data.insert(). - document[resource_def['id_field']] = \ - document.get(resource_def['id_field'], ids.pop(0)) + id_ = document.get(id_field, ids.pop(0)) + document[id_field] = id_ # build the full response document result = document - build_response_document( - result, resource, embedded_fields, document) + build_response_document(result, resource, embedded_fields, document) # add extra write meta data result[config.STATUS] = config.STATUS_OK @@ -294,4 +300,10 @@ def post_internal(resource, payl=None, skip_validation=False): % failures, } - return response, None, None, return_code + location_header = ( + None + if return_code != 201 or not documents + else [("Location", "%s/%s" % (resource_link(), documents[0][id_field]))] + ) + + return response, None, None, return_code, location_header diff --git a/eve/methods/put.py b/eve/methods/put.py index ef0347eaa..448c0a054 100644 --- a/eve/methods/put.py +++ b/eve/methods/put.py @@ -4,31 +4,34 @@ eve.methods.put ~~~~~~~~~~~~~~~ - This module imlements the PUT method. + This module implements the PUT method. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ -from datetime import datetime -from flask import current_app as app, abort +from cerberus.validator import DocumentError +from flask import abort +from flask import current_app as app from werkzeug import exceptions -from eve.auth import requires_auth -from eve.defaults import resolve_default_values -from eve.methods.common import get_document, parse, payload as payload_, \ - ratelimit, pre_event, store_media_files, resolve_user_restricted_access, \ - resolve_embedded_fields, build_response_document, marshal_write_response, \ - resolve_sub_resource_path, resolve_document_etag, oplog_push +from eve.auth import auth_field_and_value, requires_auth +from eve.methods.common import (build_response_document, get_document, + marshal_write_response, oplog_push, parse) +from eve.methods.common import payload as payload_ +from eve.methods.common import (pre_event, ratelimit, resolve_document_etag, + resolve_embedded_fields, + resolve_sub_resource_path, + resolve_user_restricted_access, + store_media_files, utcnow) from eve.methods.post import post_internal from eve.utils import config, debug_error_message, parse_request -from eve.validation import ValidationError -from eve.versioning import resolve_document_version, \ - insert_versioning_documents, late_versioning_catch +from eve.versioning import (insert_versioning_documents, late_versioning_catch, + resolve_document_version) @ratelimit() -@requires_auth('item') +@requires_auth("item") @pre_event def put(resource, payload=None, **lookup): """ @@ -39,17 +42,19 @@ def put(resource, payload=None, **lookup): .. versionchanged:: 0.5 Split into put() and put_internal(). """ - return put_internal(resource, payload, concurrency_check=True, - skip_validation=False, **lookup) + return put_internal( + resource, payload, concurrency_check=True, skip_validation=False, **lookup + ) -def put_internal(resource, payload=None, concurrency_check=False, - skip_validation=False, **lookup): - """ Intended for internal put calls, this method is not rate limited, +def put_internal( + resource, payload=None, concurrency_check=False, skip_validation=False, **lookup +): + """Intended for internal put calls, this method is not rate limited, authentication is not checked, pre-request events are not raised, and concurrency checking is optional. Performs a document replacement. Updates are first validated against the resource schema. If validation - passes, the document is repalced and an OK status update is returned. + passes, the document is replaced and an OK status update is returned. If validation fails a set of validation issues is returned. :param resource: the name of the resource to which the document belongs. @@ -70,7 +75,7 @@ def put_internal(resource, payload=None, concurrency_check=False, Allow restoring soft deleted documents via PUT .. versionchanged:: 0.5 - Back to resolving default values after validaton as now the validator + Back to resolving default values after validation as now the validator can properly validate dependency even when some have default values. See #353. Original put() has been split into put() and put_internal(). @@ -81,7 +86,7 @@ def put_internal(resource, payload=None, concurrency_check=False, through. Fixes #395. .. versionchanged:: 0.4 - Allow abort() to be inoked by callback functions. + Allow abort() to be invoked by callback functions. Resolve default values before validation is performed. See #353. Raise 'on_replace' instead of 'on_insert'. The callback function gets the document (as opposed to a list of just 1 document) as an argument. @@ -97,7 +102,7 @@ def put_internal(resource, payload=None, concurrency_check=False, Use the new STATUS setting. Use the new ISSUES setting. Raise pre_ event. - explictly resolve default values instead of letting them be resolved + explicitly resolve default values instead of letting them be resolved by common.parse. This avoids a validation error when a read-only field also has a default value. @@ -107,30 +112,47 @@ def put_internal(resource, payload=None, concurrency_check=False, .. versionadded:: 0.1.0 """ - resource_def = app.config['DOMAIN'][resource] - schema = resource_def['schema'] - validator = app.validator(schema, resource) + resource_def = app.config["DOMAIN"][resource] + schema = resource_def["schema"] + validator = app.validator( + schema, resource=resource, allow_unknown=resource_def["allow_unknown"] + ) if payload is None: payload = payload_() - original = get_document(resource, concurrency_check, **lookup) + # Retrieve the original document without checking user-restricted access, + # but returning the document owner in the projection. This allows us to + # prevent PUT if the document exists, but is owned by a different user + # than the currently authenticated one. + original = get_document( + resource, + concurrency_check, + check_auth_value=False, + force_auth_field_projection=True, + **lookup + ) if not original: if config.UPSERT_ON_PUT: - id = lookup[resource_def['id_field']] + id = lookup[resource_def["id_field"]] # this guard avoids a bson dependency, which would be needed if we # wanted to use 'isinstance'. Should also be slightly faster. - if schema[resource_def['id_field']].get('type', '') == 'objectid': + if schema[resource_def["id_field"]].get("type", "") == "objectid": id = str(id) - payload[resource_def['id_field']] = id + payload[resource_def["id_field"]] = id return post_internal(resource, payl=payload) - else: - abort(404) + abort(404) + + # If the document exists, but is owned by someone else, return + # 403 Forbidden + auth_field, request_auth_value = auth_field_and_value(resource) + if auth_field and original.get(auth_field) != request_auth_value: + abort(403) last_modified = None etag = None issues = {} - object_id = original[resource_def['id_field']] + object_id = original[resource_def["id_field"]] response = {} @@ -146,8 +168,7 @@ def put_internal(resource, payload=None, concurrency_check=False, if skip_validation: validation = True else: - validation = validator.validate_replace(document, object_id, - original) + validation = validator.validate_replace(document, object_id, original) # Apply coerced values document = validator.document @@ -156,10 +177,10 @@ def put_internal(resource, payload=None, concurrency_check=False, late_versioning_catch(original, resource) # update meta - last_modified = datetime.utcnow().replace(microsecond=0) + last_modified = utcnow() document[config.LAST_UPDATED] = last_modified document[config.DATE_CREATED] = original[config.DATE_CREATED] - if resource_def['soft_delete'] is True: + if resource_def["soft_delete"] is True: # PUT with soft delete enabled should always set the DELETED # field to False. We are either carrying through un-deleted # status, or restoring a soft deleted document @@ -168,13 +189,12 @@ def put_internal(resource, payload=None, concurrency_check=False, # id_field not in document means it is not being automatically # handled (it has been set to a field which exists in the # resource schema. - if resource_def['id_field'] not in document: - document[resource_def['id_field']] = object_id + if resource_def["id_field"] not in document: + document[resource_def["id_field"]] = object_id resolve_user_restricted_access(document, resource) - resolve_default_values(document, resource_def['defaults']) store_media_files(document, resource, original) - resolve_document_version(document, resource, 'PUT', original) + resolve_document_version(document, resource, "PUT", original) # notify callbacks getattr(app, "on_replace")(resource, document, original) @@ -184,15 +204,13 @@ def put_internal(resource, payload=None, concurrency_check=False, # write to db try: - app.data.replace( - resource, object_id, document, original) + app.data.replace(resource, object_id, document, original) except app.data.OriginalChangedError: if concurrency_check: - abort(412, - description='Client and server etags don\'t match') + abort(412, description="Client and server etags don't match") # update oplog if needed - oplog_push(resource, document, 'PUT') + oplog_push(resource, document, "PUT") insert_versioning_documents(resource, document) @@ -201,27 +219,24 @@ def put_internal(resource, payload=None, concurrency_check=False, getattr(app, "on_replaced_%s" % resource)(document, original) # build the full response document - build_response_document( - document, resource, embedded_fields, document) + build_response_document(document, resource, embedded_fields, document) response = document if config.IF_MATCH: etag = response[config.ETAG] else: issues = validator.errors - except ValidationError as e: + except DocumentError as e: # TODO should probably log the error and abort 400 instead (when we # got logging) - issues['validator exception'] = str(e) + issues["validator exception"] = str(e) except exceptions.HTTPException as e: raise e except Exception as e: # consider all other exceptions as Bad Requests app.logger.exception(e) - abort(400, description=debug_error_message( - 'An exception occurred: %s' % e - )) + abort(400, description=debug_error_message("An exception occurred: %s" % e)) - if len(issues): + if issues: response[config.ISSUES] = issues response[config.STATUS] = config.STATUS_ERR status = config.VALIDATION_ERROR_STATUS diff --git a/eve/render.py b/eve/render.py index 1f5a403a9..5a060157a 100644 --- a/eve/render.py +++ b/eve/render.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*-) +# -*- coding: utf-8 -*- """ eve.render @@ -6,36 +6,30 @@ Implements proper, automated rendering for Eve responses. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ +import datetime import re import time -import datetime -import simplejson as json -from werkzeug import utils +from collections import OrderedDict # noqa from functools import wraps -from eve.methods.common import get_rate_limit -from eve.utils import date_to_str, date_to_rfc1123, config, \ - debug_error_message -from flask import make_response, request, Response, current_app as app, abort -try: - from collections import OrderedDict # noqa -except ImportError: - # Python 2.6 needs this back-port - from ordereddict import OrderedDict +import simplejson as json +from flask import Response, abort +from flask import current_app as app +from flask import make_response, request +from markupsafe import escape +from werkzeug import utils -# mapping between supported mime types and render functions. -_MIME_TYPES = [ - {'mime': ('application/json',), 'renderer': 'render_json', 'tag': 'JSON'}, - {'mime': ('application/xml', 'text/xml', 'application/x-xml',), - 'renderer': 'render_xml', 'tag': 'XML'}] +from eve.methods.common import get_rate_limit +from eve.utils import (config, date_to_rfc1123, date_to_str, + debug_error_message, import_from_string) def raise_event(f): - """ Raises both general and resource-level events after the decorated + """Raises both general and resource-level events after the decorated function has been executed. Returns both the flask.request object and the response payload to the callback. @@ -52,25 +46,27 @@ def raise_event(f): .. versionadded:: 0.0.6 """ + @wraps(f) def decorated(*args, **kwargs): r = f(*args, **kwargs) method = request.method - if method in ('GET', 'POST', 'PATCH', 'DELETE', 'PUT'): - event_name = 'on_post_' + method + if method in ("GET", "POST", "PATCH", "DELETE", "PUT"): + event_name = "on_post_" + method resource = args[0] if args else None # general hook getattr(app, event_name)(resource, request, r) if resource: # resource hook - getattr(app, event_name + '_' + resource)(request, r) + getattr(app, event_name + "_" + resource)(request, r) return r + return decorated @raise_event def send_response(resource, response): - """ Prepares the response for the client. + """Prepares the response for the client. :param resource: the resource involved. :param response: either a flask.Response object or a tuple. The former will @@ -92,13 +88,13 @@ def send_response(resource, response): """ if isinstance(response, Response): return response - else: - return _prepare_response(resource, *response if response else [None]) + return _prepare_response(resource, *response if response else [None]) -def _prepare_response(resource, dct, last_modified=None, etag=None, - status=200, headers=None): - """ Prepares the response object according to the client request and +def _prepare_response( + resource, dct, last_modified=None, etag=None, status=200, headers=None +): + """Prepares the response object according to the client request and available renderers, making sure that all accessory directives (caching, etag, last-modified) are present. @@ -109,7 +105,7 @@ def _prepare_response(resource, dct, last_modified=None, etag=None, :param status: response status. .. versionchanged:: 0.7 - Add support for regexes in X_DOMAINS values. Closes #660. + Add support for regexes in X_DOMAINS_RE. Closes #660, #974. ETag value now surrounded by double quotes. Closes #794. .. versionchanged:: 0.6 @@ -139,60 +135,82 @@ def _prepare_response(resource, dct, last_modified=None, etag=None, .. versionadded:: 0.0.4 """ - if request.method == 'OPTIONS': + if request.method == "OPTIONS": resp = app.make_default_options_response() + elif isinstance(dct, Response): + resp = dct else: # obtain the best match between client's request and available mime # types, along with the corresponding render function. - mime, renderer = _best_mime() + mime, renderer_cls = _best_mime() # invoke the render function and obtain the corresponding rendered item - rendered = globals()[renderer](dct) + rendered = renderer_cls().render(dct) - # JSONP + # JSONP (deprecated) if config.JSONP_ARGUMENT: jsonp_arg = config.JSONP_ARGUMENT - if jsonp_arg in request.args and 'json' in mime: + if jsonp_arg in request.args and "json" in mime: callback = request.args.get(jsonp_arg) + if not re.match(r"^[a-zA-Z_$][\w$.]*$", callback): + abort(400, description="Invalid JSONP callback name") rendered = "%s(%s)" % (callback, rendered) - # build the main wsgi rensponse object + # build the main wsgi response object resp = make_response(rendered, status) resp.mimetype = mime + resp.autocorrect_location_header = True # extra headers if headers: for header, value in headers: - if header != 'Content-Type': + if header != "Content-Type": resp.headers.add(header, value) # cache directives - if request.method in ('GET', 'HEAD'): + if request.method in ("GET", "HEAD"): if resource: - cache_control = config.DOMAIN[resource]['cache_control'] - expires = config.DOMAIN[resource]['cache_expires'] + cache_control = config.DOMAIN[resource]["cache_control"] + expires = config.DOMAIN[resource]["cache_expires"] else: cache_control = config.CACHE_CONTROL expires = config.CACHE_EXPIRES if cache_control: - resp.headers.add('Cache-Control', cache_control) + resp.headers.add("Cache-Control", cache_control) if expires: resp.expires = time.time() + expires # etag and last-modified if etag: - resp.headers.add('ETag', '"' + etag + '"') + resp.headers.add("ETag", '"' + etag + '"') if last_modified: - resp.headers.add('Last-Modified', date_to_rfc1123(last_modified)) + resp.headers.add("Last-Modified", date_to_rfc1123(last_modified)) # CORS - origin = request.headers.get('Origin') - if origin and config.X_DOMAINS: - if isinstance(config.X_DOMAINS, str): + origin = request.headers.get("Origin") + if origin and (config.X_DOMAINS or config.X_DOMAINS_RE): + if config.X_DOMAINS is None: + domains = [] + elif isinstance(config.X_DOMAINS, str): domains = [config.X_DOMAINS] else: domains = config.X_DOMAINS + if config.X_DOMAINS_RE is None: + domains_re = [] + elif isinstance(config.X_DOMAINS_RE, str): + domains_re = [config.X_DOMAINS_RE] + else: + domains_re = config.X_DOMAINS_RE + + # precompile regexes and ignore invalids + domains_re_compiled = [] + for domain_re in domains_re: + try: + domains_re_compiled.append(re.compile(domain_re)) + except re.error: + continue + if config.X_HEADERS is None: headers = [] elif isinstance(config.X_HEADERS, str): @@ -211,246 +229,337 @@ def _prepare_response(resource, dct, last_modified=None, etag=None, # is "true" allow_credentials = config.X_ALLOW_CREDENTIALS is True - methods = app.make_default_options_response().headers.get('allow', '') + methods = app.make_default_options_response().headers.get("allow", "") - if '*' in domains: - resp.headers.add('Access-Control-Allow-Origin', origin) - resp.headers.add('Vary', 'Origin') - elif any(re.match(re.escape(domain), origin) for domain in domains): - resp.headers.add('Access-Control-Allow-Origin', origin) + if "*" in domains: + resp.headers.add("Access-Control-Allow-Origin", origin) + resp.headers.add("Vary", "Origin") + elif any(origin == domain for domain in domains): + resp.headers.add("Access-Control-Allow-Origin", origin) + elif any(domain.match(origin) for domain in domains_re_compiled): + resp.headers.add("Access-Control-Allow-Origin", origin) else: - resp.headers.add('Access-Control-Allow-Origin', '') - resp.headers.add('Access-Control-Allow-Headers', ', '.join(headers)) - resp.headers.add('Access-Control-Expose-Headers', - ', '.join(expose_headers)) - resp.headers.add('Access-Control-Allow-Methods', methods) - resp.headers.add('Access-Control-Max-Age', config.X_MAX_AGE) + resp.headers.add("Access-Control-Allow-Origin", "") + resp.headers.add("Access-Control-Allow-Headers", ", ".join(headers)) + resp.headers.add("Access-Control-Expose-Headers", ", ".join(expose_headers)) + resp.headers.add("Access-Control-Allow-Methods", methods) + resp.headers.add("Access-Control-Max-Age", config.X_MAX_AGE) if allow_credentials: - resp.headers.add('Access-Control-Allow-Credentials', "true") + resp.headers.add("Access-Control-Allow-Credentials", "true") # Rate-Limiting limit = get_rate_limit() if limit and limit.send_x_headers: - resp.headers.add('X-RateLimit-Remaining', str(limit.remaining)) - resp.headers.add('X-RateLimit-Limit', str(limit.limit)) - resp.headers.add('X-RateLimit-Reset', str(limit.reset)) + resp.headers.add("X-RateLimit-Remaining", str(limit.remaining)) + resp.headers.add("X-RateLimit-Limit", str(limit.limit)) + resp.headers.add("X-RateLimit-Reset", str(limit.reset)) return resp def _best_mime(): - """ Returns the best match between the requested mime type and the + """Returns the best match between the requested mime type and the ones supported by Eve. Along with the mime, also the corresponding render function is returns. + .. versionchanged:: 0.8 + Support for optional renderers via RENDERERS. XML and JSON + configuration keywords removed. + .. versionchanged:: 0.3 Support for optional renderers via XML and JSON configuration keywords. """ supported = [] renders = {} - for mime in _MIME_TYPES: - # only mime types that have not been disabled via configuration - if app.config.get(mime['tag'], True): - for mime_type in mime['mime']: - supported.append(mime_type) - renders[mime_type] = mime['renderer'] + for renderer_cls in app.config.get("RENDERERS"): + renderer = import_from_string(renderer_cls) + for mime_type in renderer.mime: + supported.append(mime_type) + renders[mime_type] = renderer if len(supported) == 0: - abort(500, description=debug_error_message( - 'Configuration error: no supported mime types') + abort( + 500, + description=debug_error_message( + "Configuration error: no supported mime types" + ), ) - best_match = request.accept_mimetypes.best_match(supported) or \ - supported[0] + best_match = request.accept_mimetypes.best_match(supported) or supported[0] return best_match, renders[best_match] -def render_json(data): - """ JSON render function - - .. versionchanged:: 0.2 - Json encoder class is now inferred by the active data layer, allowing - for customized, data-aware JSON encoding. - - .. versionchanged:: 0.1.0 - Support for optional HATEOAS. - """ - return json.dumps(data, cls=app.data.json_encoder_class, - sort_keys=config.JSON_SORT_KEYS) - - -def render_xml(data): - """ XML render function. - - :param data: the data stream to be rendered as xml. - - .. versionchanged:: 0.4 - Support for pagination info (_meta). - - .. versionchanged:: 0.2 - Use the new ITEMS configuration setting. - - .. versionchanged:: 0.1.0 - Support for optional HATEOAS. - - .. versionchanged:: 0.0.3 - Support for HAL-like hyperlinks and resource descriptors. - """ - if isinstance(data, list): - data = {config.ITEMS: data} - - xml = '' - if data: - xml += xml_root_open(data) - xml += xml_add_links(data) - xml += xml_add_meta(data) - xml += xml_add_items(data) - xml += xml_root_close() - return xml - - -def xml_root_open(data): - """ Returns the opening tag for the XML root node. If the datastream - includes informations about resource endpoints (href, title), they will - be added as node attributes. The resource endpoint is then removed to allow - for further processing of the datastream. - - :param data: the data stream to be rendered as xml. - - .. versionchanged:: 0.1.0 - Support for optional HATEOAS. - - .. versionchanged:: 0.0.6 - Links are now properly escaped. - - .. versionadded:: 0.0.3 - """ - links = data.get(config.LINKS) - href = title = '' - if links and 'self' in links: - self_ = links.pop('self') - href = ' href="%s" ' % utils.escape(self_['href']) - if 'title' in self_: - title = ' title="%s" ' % self_['title'] - return '' % (href, title) - - -def xml_add_meta(data): - """ Returns a meta node with page, total, max_results fields. - - :param data: the data stream to be rendered as xml. - - .. versionchanged:: 0.5 - Always return ordered items (#441). - - .. versionadded:: 0.4 - """ - xml = '' - meta = [] - if data.get(config.META): - ordered_meta = OrderedDict(sorted(data[config.META].items())) - for name, value in ordered_meta.items(): - meta.append('<%s>%d' % (name, value, name)) - if meta: - xml = '<%s>%s' % (config.META, ''.join(meta), config.META) - return xml - - -def xml_add_links(data): - """ Returns as many nodes as there are in the datastream. The links - are then removed from the datastream to allow for further processing. - - :param data: the data stream to be rendered as xml. - - .. versionchanged:: 0.5 - Always return ordered items (#441). - - .. versionchanged:: 0.0.6 - Links are now properly escaped. +class Renderer(): + """Base class for all the renderers. Renderer should set valid `mime` + attr and have `.render()` method implemented. - .. versionadded:: 0.0.3 """ - xml = '' - chunk = '' - links = data.pop(config.LINKS, {}) - ordered_links = OrderedDict(sorted(links.items())) - for rel, link in ordered_links.items(): - if isinstance(link, list): - xml += ''.join([chunk % (rel, utils.escape(d['href']), - utils.escape(d['title'])) for d in link]) - else: - xml += ''.join(chunk % (rel, utils.escape(link['href']), - link['title'])) - return xml - -def xml_add_items(data): - """ When this function is called the datastream can only contain a `_items` - list, or a dictionary. If a list, each item is a resource which rendered as - XML. If a dictionary, it will be rendered as XML. + mime = tuple() - :param data: the data stream to be rendered as xml. + def render(self, data): + raise NotImplementedError("Renderer .render() method is not " "implemented") - .. versionadded:: 0.0.3 - """ - try: - xml = ''.join([xml_item(item) for item in data[config.ITEMS]]) - except: - xml = xml_dict(data) - return xml +class JSONRenderer(Renderer): + """JSON renderer class based on `simplejson` package.""" -def xml_item(item): - """ Represents a single resource (member of a collection) as XML. + mime = ("application/json",) - :param data: the data stream to be rendered as xml. + def render(self, data): + """JSON render function - .. versionadded:: 0.0.3 - """ - xml = xml_root_open(item) - xml += xml_add_links(item) - xml += xml_dict(item) - xml += xml_root_close() - return xml + :param data: the data stream to be rendered as json. + .. versionchanged:: 0.2 + Json encoder class is now inferred by the active data layer, + allowing for customized, data-aware JSON encoding. -def xml_root_close(): - """ Returns the closing tag of the XML root node. + .. versionchanged:: 0.1.0 + Support for optional HATEOAS. + """ + set_indent = None - .. versionadded:: 0.0.3 - """ - return '' - - -def xml_dict(data): - """ Renders a dict as XML. - - :param data: the data stream to be rendered as xml. - - .. versionchanged:: 0.5 - Always return ordered items (#441). + # make pretty prints available + if "GET" in request.method and "pretty" in request.args: + set_indent = 4 + return json.dumps( + data, + indent=set_indent, + cls=app.data.json_encoder_class, + sort_keys=config.JSON_SORT_KEYS, + ) - .. versionchanged:: 0.2 - Leaf values are now properly escaped. - .. versionadded:: 0.0.3 - """ - xml = '' - ordered_items = OrderedDict(sorted(data.items())) - for k, v in ordered_items.items(): - if isinstance(v, datetime.datetime): - v = date_to_str(v) - elif isinstance(v, (datetime.time, datetime.date)): - v = v.isoformat() - if not isinstance(v, list): - v = [v] - for value in v: - if isinstance(value, dict): - links = xml_add_links(value) - xml += "<%s>" % k - xml += xml_dict(value) - xml += links - xml += "" % k +class XMLRenderer(Renderer): + """XML renderer class.""" + + mime = ("application/xml", "text/xml", "application/x-xml") + tag = "XML" + + def render(self, data): + """XML render function. + + :param data: the data stream to be rendered as xml. + + .. versionchanged:: 0.4 + Support for pagination info (_meta). + + .. versionchanged:: 0.2 + Use the new ITEMS configuration setting. + + .. versionchanged:: 0.1.0 + Support for optional HATEOAS. + + .. versionchanged:: 0.0.3 + Support for HAL-like hyperlinks and resource descriptors. + """ + if isinstance(data, list): + data = {config.ITEMS: data} + + xml = "" + if data: + xml += self.xml_root_open(data) + xml += self.xml_add_links(data) + xml += self.xml_add_meta(data) + xml += self.xml_add_items(data) + xml += self.xml_root_close() + return xml + + @classmethod + def xml_root_open(cls, data): + """Returns the opening tag for the XML root node. If the datastream + includes information about resource endpoints (href, title), they will + be added as node attributes. The resource endpoint is then removed to + allow for further processing of the datastream. + + :param data: the data stream to be rendered as xml. + + .. versionchanged:: 0.1.0 + Support for optional HATEOAS. + + .. versionchanged:: 0.0.6 + Links are now properly escaped. + + .. versionadded:: 0.0.3 + """ + links = data.get(config.LINKS) + href = title = "" + if links and "self" in links: + self_ = links.pop("self") + href = ' href="%s" ' % escape(self_["href"]) + if "title" in self_: + title = ' title="%s" ' % self_["title"] + return "" % (href, title) + + @classmethod + def xml_add_meta(cls, data): + """Returns a meta node with page, total, max_results fields. + + :param data: the data stream to be rendered as xml. + + .. versionchanged:: 0.5 + Always return ordered items (#441). + + .. versionadded:: 0.4 + """ + xml = "" + meta = [] + if data.get(config.META): + ordered_meta = OrderedDict(sorted(data[config.META].items())) + for name, value in ordered_meta.items(): + meta.append("<%s>%d" % (name, value, name)) + if meta: + xml = "<%s>%s" % (config.META, "".join(meta), config.META) + return xml + + @classmethod + def xml_add_links(cls, data): + """Returns as many nodes as there are in the datastream. The + added links are then removed from the datastream to allow for further + processing. + + :param data: the data stream to be rendered as xml. + + .. versionchanged:: 0.8.2 + Keep data relation links in the datastream as they will be + processed as node attributes in xml_dict + + .. versionchanged:: 0.5 + Always return ordered items (#441). + + .. versionchanged:: 0.0.6 + Links are now properly escaped. + + .. versionadded:: 0.0.3 + """ + xml = "" + chunk = '' + links = data.pop(config.LINKS, {}) + ordered_links = OrderedDict(sorted(links.items())) + for rel, link in ordered_links.items(): + if rel == "related": + # add data relation links back for + # future processing of hateoas attributes + data.update({config.LINKS: {rel: link}}) + + elif isinstance(link, list): + xml += "".join( + chunk % (rel, escape(d["href"]), escape(d["title"])) + for d in link + ) else: - xml += "<%s>%s" % (k, utils.escape(value), k) - return xml + xml += "".join(chunk % (rel, escape(link["href"]), link["title"])) + return xml + + @classmethod + def xml_add_items(cls, data): + """When this function is called the datastream can only contain + a `_items` list, or a dictionary. If a list, each item is a resource + which rendered as XML. If a dictionary, it will be rendered as XML. + + :param data: the data stream to be rendered as xml. + + .. versionadded:: 0.0.3 + """ + try: + xml = "".join(cls.xml_item(item) for item in data[config.ITEMS]) + except Exception: + xml = cls.xml_dict(data) + return xml + + @classmethod + def xml_item(cls, item): + """Represents a single resource (member of a collection) as XML. + + :param data: the data stream to be rendered as xml. + + .. versionadded:: 0.0.3 + """ + xml = cls.xml_root_open(item) + xml += cls.xml_add_links(item) + xml += cls.xml_dict(item) + xml += cls.xml_root_close() + return xml + + @classmethod + def xml_root_close(cls): + """Returns the closing tag of the XML root node. + + .. versionadded:: 0.0.3 + """ + return "" + + @classmethod + def xml_dict(cls, data): + """Renders a dict as XML. + + :param data: the data stream to be rendered as xml. + + .. versionchanged:: 0.8.2 + Renders hateoas attributes on XML nodes. See #1204. + + .. versionchanged:: 0.5 + Always return ordered items (#441). + + .. versionchanged:: 0.2 + Leaf values are now properly escaped. + + .. versionadded:: 0.0.3 + """ + xml = "" + related_links = data.pop(config.LINKS, {}).pop("related", {}) + ordered_items = OrderedDict(sorted(data.items())) + for k, v in ordered_items.items(): + if isinstance(v, datetime.datetime): + v = date_to_str(v) + elif isinstance(v, (datetime.time, datetime.date)): + v = v.isoformat() + if not isinstance(v, list): + v = [v] + for idx, value in enumerate(v): + if isinstance(value, dict): + links = cls.xml_add_links(value) + xml += cls.xml_field_open(k, idx, related_links) + xml += cls.xml_dict(value) + xml += links + xml += cls.xml_field_close(k) + else: + xml += cls.xml_field_open(k, idx, related_links) + xml += "%s" % escape(value) + xml += cls.xml_field_close(k) + return xml + + @classmethod + def xml_field_open(cls, field, idx, related_links): + """Returns opening tag for XML field element node. + + :param field: field name for the element node + :param idx: the index in the data relation links if serializing a list of same field to XML + :param related_links: a dictionary that stores all data relation links + + .. versionadded:: 0.8.2 + """ + if field in related_links: + if isinstance(related_links[field], list): + return '<%s href="%s" title="%s">' % ( + field, + escape(related_links[field][idx]["href"]), + related_links[field][idx]["title"], + ) + return '<%s href="%s" title="%s">' % ( + field, + escape(related_links[field]["href"]), + related_links[field]["title"], + ) + return "<%s>" % field + + @classmethod + def xml_field_close(cls, field): + """Returns closing tag of XML field element node. + + :param field: field name for the element node + + .. versionadded:: 0.8.2 + """ + return "" % field diff --git a/eve/tests/__init__.py b/eve/tests/__init__.py deleted file mode 100644 index 699abc6aa..000000000 --- a/eve/tests/__init__.py +++ /dev/null @@ -1,550 +0,0 @@ -# -*- coding: utf-8 -*- - -import unittest -import eve -import string -import random -import os -import simplejson as json -from datetime import datetime, timedelta -from flask_pymongo import MongoClient -from bson import ObjectId -from eve.tests.test_settings import MONGO_PASSWORD, MONGO_USERNAME, \ - MONGO_DBNAME, DOMAIN, MONGO_HOST, MONGO_PORT -from eve import ISSUES, ETAG -from eve.utils import date_to_str -try: - from urlparse import parse_qs, urlparse -except ImportError: - from urllib.parse import parse_qs, urlparse - - -class ValueStack(object): - """ - Descriptor to store multiple assignments in an attribute. - - Due to the multiple self.app = assignments in tests, it is difficult to - keep track by hand of the applications created in order to close their - database connections. This descriptor helps with it. - """ - def __init__(self, on_delete): - """ - :param on_delete: Action to execute when the attribute is deleted - """ - self.elements = [] - self.on_delete = on_delete - - def __set__(self, obj, val): - self.elements.append(val) - - def __get__(self, obj, objtype): - return self.elements[-1] if self.elements else None - - def __delete__(self, obj): - for item in self.elements: - self.on_delete(item) - self.elements = [] - - -def close_pymongo_connection(app): - """ - Close the pymongo connection in an eve/flask app - """ - if 'pymongo' not in app.extensions: - return - del app.extensions['pymongo'] - del app.media - - -class TestMinimal(unittest.TestCase): - """ Start the building of the tests for an application - based on Eve by subclassing this class and provide proper settings - using :func:`setUp()` - """ - app = ValueStack(close_pymongo_connection) - - def setUp(self, settings_file=None, url_converters=None): - """ Prepare the test fixture - - :param settings_file: the name of the settings file. Defaults - to `eve/tests/test_settings.py`. - """ - self.this_directory = os.path.dirname(os.path.realpath(__file__)) - if settings_file is None: - # Load the settings file, using a robust path - settings_file = os.path.join(self.this_directory, - 'test_settings.py') - - self.connection = None - self.known_resource_count = 101 - self.setupDB() - - self.settings_file = settings_file - self.app = eve.Eve(settings=self.settings_file, - url_converters=url_converters) - - self.test_client = self.app.test_client() - - self.domain = self.app.config['DOMAIN'] - - def tearDown(self): - del self.app - self.dropDB() - - def assert200(self, status): - self.assertEqual(status, 200) - - def assert201(self, status): - self.assertEqual(status, 201) - - def assert204(self, status): - self.assertEqual(status, 204) - - def assert301(self, status): - self.assertEqual(status, 301) - - def assert304(self, status): - self.assertEqual(status, 304) - - def assert404(self, status): - self.assertEqual(status, 404) - - def assert422(self, status): - self.assertEqual(status, 422) - - def get(self, resource, query='', item=None): - if resource in self.domain: - resource = self.domain[resource]['url'] - if item: - request = '/%s/%s%s' % (resource, item, query) - else: - request = '/%s%s' % (resource, query) - - r = self.test_client.get(request) - return self.parse_response(r) - - def post(self, url, data, headers=None, content_type='application/json'): - if headers is None: - headers = [] - headers.append(('Content-Type', content_type)) - r = self.test_client.post(url, data=json.dumps(data), headers=headers) - return self.parse_response(r) - - def put(self, url, data, headers=None): - if headers is None: - headers = [] - headers.append(('Content-Type', 'application/json')) - r = self.test_client.put(url, data=json.dumps(data), headers=headers) - return self.parse_response(r) - - def patch(self, url, data, headers=None): - if headers is None: - headers = [] - headers.append(('Content-Type', 'application/json')) - r = self.test_client.patch(url, data=json.dumps(data), headers=headers) - return self.parse_response(r) - - def delete(self, url, headers=None): - r = self.test_client.delete(url, headers=headers) - return self.parse_response(r) - - def parse_response(self, r): - try: - v = json.loads(r.get_data()) - except json.JSONDecodeError: - v = None - return v, r.status_code - - def assertValidationErrorStatus(self, status): - self.assertEqual(status, - self.app.config.get('VALIDATION_ERROR_STATUS')) - - def assertValidationError(self, response, matches): - self.assertTrue(eve.STATUS in response) - self.assertTrue(eve.STATUS_ERR in response[eve.STATUS]) - self.assertTrue(ISSUES in response) - issues = response[ISSUES] - self.assertTrue(len(issues)) - - for k, v in matches.items(): - self.assertTrue(k in issues) - self.assertTrue(v in issues[k]) - - def assertExpires(self, resource): - # TODO if we ever get access to response.date (it is None), compare - # it with Expires - r = self.test_client.get(resource) - - expires = r.headers.get('Expires') - self.assertTrue(expires is not None) - - def assertCacheControl(self, resource): - r = self.test_client.get(resource) - - cache_control = r.headers.get('Cache-Control') - self.assertTrue(cache_control is not None) - self.assertEqual(cache_control, - self.domain[self.known_resource]['cache_control']) - - def assertIfModifiedSince(self, resource): - r = self.test_client.get(resource) - - last_modified = r.headers.get('Last-Modified') - self.assertTrue(last_modified is not None) - r = self.test_client.get(resource, headers=[('If-Modified-Since', - last_modified)]) - self.assert304(r.status_code) - self.assertTrue(not r.get_data()) - - def assertItem(self, item, resource): - self.assertEqual(type(item), dict) - - updated_on = item.get(self.app.config['LAST_UPDATED']) - self.assertTrue(updated_on is not None) - try: - datetime.strptime(updated_on, self.app.config['DATE_FORMAT']) - except Exception as e: - self.fail('Cannot convert field "%s" to datetime: %s' % - (self.app.config['LAST_UPDATED'], e)) - - created_on = item.get(self.app.config['DATE_CREATED']) - self.assertTrue(updated_on is not None) - try: - datetime.strptime(created_on, self.app.config['DATE_FORMAT']) - except Exception as e: - self.fail('Cannot convert field "%s" to datetime: %s' % - (self.app.config['DATE_CREATED'], e)) - - link = item.get('_links') - _id = item.get(self.domain[resource]['id_field']) - self.assertItemLink(link, _id) - - def assertPagination(self, response, page, total, max_results): - p_key, mr_key = self.app.config['QUERY_PAGE'], \ - self.app.config['QUERY_MAX_RESULTS'] - self.assertTrue(self.app.config['META'] in response) - meta = response.get(self.app.config['META']) - self.assertTrue(p_key in meta) - self.assertTrue(mr_key in meta) - self.assertTrue('total' in meta) - self.assertEqual(meta[p_key], page) - self.assertEqual(meta[mr_key], max_results) - self.assertEqual(meta['total'], total) - - def assertHomeLink(self, links): - self.assertTrue('parent' in links) - link = links['parent'] - self.assertTrue('title' in link) - self.assertTrue('href' in link) - self.assertEqual('home', link['title']) - self.assertEqual("/", link['href']) - - def assertResourceLink(self, links, resource): - self.assertTrue('self' in links) - link = links['self'] - self.assertTrue('title' in link) - self.assertTrue('href' in link) - url = self.domain[resource]['url'] - self.assertEqual(url, link['title']) - self.assertEqual("%s" % url, link['href']) - - def assertCollectionLink(self, links, resource): - self.assertTrue('collection' in links) - link = links['collection'] - self.assertTrue('title' in link) - self.assertTrue('href' in link) - url = self.domain[resource]['url'] - self.assertEqual(url, link['title']) - self.assertEqual("%s" % url, link['href']) - - def assertNextLink(self, links, page): - self.assertTrue('next' in links) - link = links['next'] - self.assertTrue('title' in link) - self.assertTrue('href' in link) - self.assertEqual('next page', link['title']) - self.assertTrue("%s=%d" % (self.app.config['QUERY_PAGE'], page) - in link['href']) - - def assertPrevLink(self, links, page): - self.assertTrue('prev' in links) - link = links['prev'] - self.assertTrue('title' in link) - self.assertTrue('href' in link) - self.assertEqual('previous page', link['title']) - if page > 1: - self.assertTrue("%s=%d" % (self.app.config['QUERY_PAGE'], page) - in link['href']) - - def assertItemLink(self, links, item_id): - self.assertTrue('self' in links) - link = links['self'] - # TODO we are too deep here to get a hold of the due title. Should fix. - self.assertTrue('title' in link) - self.assertTrue('href' in link) - self.assertTrue('/%s' % item_id in link['href']) - - def assertLastLink(self, links, page): - if page: - self.assertTrue('last' in links) - link = links['last'] - self.assertTrue('title' in link) - self.assertTrue('href' in link) - self.assertEqual('last page', link['title']) - self.assertTrue("%s=%d" % (self.app.config['QUERY_PAGE'], page) - in link['href']) - else: - self.assertTrue('last' not in links) - - def assertCustomParams(self, link, params): - self.assertTrue('href' in link) - url_params = parse_qs(urlparse(link['href']).query) - for param, values in params.lists(): - self.assertTrue(param in url_params) - for value in values: - self.assertTrue(value in url_params[param]) - - def assert400(self, status): - self.assertEqual(status, 400) - - def assert401(self, status): - self.assertEqual(status, 401) - - def assert401or405(self, status): - self.assertTrue(status == 401 or 405) - - def assert403(self, status): - self.assertEqual(status, 403) - - def assert405(self, status): - self.assertEqual(status, 405) - - def assert412(self, status): - self.assertEqual(status, 412) - - def assert428(self, status): - self.assertEqual(status, 428) - - def assert500(self, status): - self.assertEqual(status, 500) - - def setupDB(self): - self.connection = MongoClient(MONGO_HOST, MONGO_PORT) - self.connection.drop_database(MONGO_DBNAME) - if MONGO_USERNAME: - self.connection[MONGO_DBNAME].add_user(MONGO_USERNAME, - MONGO_PASSWORD) - self.bulk_insert() - - def bulk_insert(self): - pass - - def dropDB(self): - self.connection = MongoClient(MONGO_HOST, MONGO_PORT) - self.connection.drop_database(MONGO_DBNAME) - self.connection.close() - - -class TestBase(TestMinimal): - - def setUp(self, url_converters=None): - super(TestBase, self).setUp(url_converters=url_converters) - - self.disabled_bulk = 'disabled_bulk' - self.disabled_bulk_url = ('/%s' % - self.domain[self.disabled_bulk]['url']) - - self.known_resource = 'contacts' - self.known_resource_url = ('/%s' % - self.domain[self.known_resource]['url']) - self.empty_resource = 'empty' - self.empty_resource_url = '/%s' % self.empty_resource - - self.unknown_resource = 'unknown' - self.unknown_resource_url = '/%s' % self.unknown_resource - self.unknown_item_id = '4f46445fc88e201858000000' - self.unknown_item_name = 'unknown' - - self.unknown_item_id_url = ('/%s/%s' % - (self.domain[self.known_resource]['url'], - self.unknown_item_id)) - self.unknown_item_name_url = ('/%s/%s' % - (self.domain[self.known_resource]['url'], - self.unknown_item_name)) - - self.readonly_resource = 'payments' - self.readonly_resource_url = ( - '/%s' % self.domain[self.readonly_resource]['url']) - - self.different_resource = 'users' - self.different_resource_url = ('/%s' % - self.domain[ - self.different_resource]['url']) - - response, _ = self.get('contacts', '?max_results=2') - contact = self.response_item(response) - self.item = contact - self.item_id = contact[self.domain['contacts']['id_field']] - self.item_name = contact['ref'] - self.item_tid = contact['tid'] - self.item_etag = contact[ETAG] - self.item_ref = contact['ref'] - self.item_id_url = ('/%s/%s' % - (self.domain[self.known_resource]['url'], - self.item_id)) - self.item_name_url = ('/%s/%s' % - (self.domain[self.known_resource]['url'], - self.item_name)) - self.alt_ref = self.response_item(response, 1)['ref'] - - response, _ = self.get('payments', '?max_results=1') - self.readonly_id = self.response_item(response)['_id'] - self.readonly_id_url = ('%s/%s' % (self.readonly_resource_url, - self.readonly_id)) - - response, _ = self.get('users') - user = self.response_item(response) - self.user_id = user[self.domain['users']['id_field']] - self.user_username = user['username'] - self.user_name = user['ref'] - self.user_etag = user[ETAG] - self.user_id_url = ('/%s/%s' % - (self.domain[self.different_resource]['url'], - self.user_id)) - self.user_username_url = ( - '/%s/%s' % (self.domain[self.different_resource]['url'], - self.user_username) - ) - - response, _ = self.get('invoices') - invoice = self.response_item(response) - self.invoice_id = invoice[self.domain['invoices']['id_field']] - self.invoice_etag = invoice[ETAG] - self.invoice_id_url = ('/%s/%s' % - (self.domain['invoices']['url'], - self.invoice_id)) - - self.epoch = date_to_str(datetime(1970, 1, 1)) - - def response_item(self, response, i=0): - if self.app.config['HATEOAS']: - return response['_items'][i] - else: - return response[i] - - def random_contacts(self, num, standard_date_fields=True): - schema = DOMAIN['contacts']['schema'] - contacts = [] - for i in range(num): - dt = datetime.utcnow().replace(microsecond=0) - contact = { - 'ref': self.random_string(schema['ref']['maxlength']), - 'prog': i, - 'role': random.choice(schema['role']['allowed']), - 'rows': self.random_rows(random.randint(0, 5)), - 'alist': self.random_list(random.randint(0, 5)), - 'location': { - 'address': 'address ' + self.random_string(5), - 'city': 'city ' + self.random_string(3), - }, - 'born': datetime.today() + timedelta( - days=random.randint(-10, 10)), - - 'tid': ObjectId(), - 'read_only_field': schema['read_only_field']['default'] - } - if standard_date_fields: - contact[eve.LAST_UPDATED] = dt - contact[eve.DATE_CREATED] = dt - - contacts.append(contact) - return contacts - - def random_users(self, num): - users = self.random_contacts(num) - for user in users: - user['username'] = self.random_string(10) - return users - - def random_payments(self, num): - payments = [] - for i in range(num): - dt = datetime.utcnow().replace(microsecond=0) - payment = { - 'a_string': self.random_string(10), - 'a_number': i, - eve.LAST_UPDATED: dt, - eve.DATE_CREATED: dt, - } - payments.append(payment) - return payments - - def random_invoices(self, num): - invoices = [] - for _ in range(num): - dt = datetime.utcnow().replace(microsecond=0) - invoice = { - 'inv_number': self.random_string(10), - eve.LAST_UPDATED: dt, - eve.DATE_CREATED: dt, - } - invoices.append(invoice) - return invoices - - def random_products(self, num): - schema = DOMAIN['products']['schema'] - products = [] - for _ in range(num): - products.append( - { - 'sku': self.random_string(schema['sku']['maxlength']), - 'title': ("Hypercube " + self.random_string(2) + - str(random.randint(100, 1000))) - } - ) - return products - - def random_string(self, num): - return (''.join(random.choice(string.ascii_uppercase) - for x in range(num))) - - def random_list(self, num): - alist = [] - for i in range(num): - alist.append(['string' + str(i), random.randint(1000, 9999)]) - return alist - - def random_rows(self, num): - schema = DOMAIN['contacts']['schema']['rows']['schema']['schema'] - rows = [] - for _ in range(num): - rows.append( - { - 'sku': self.random_string(schema['sku']['maxlength']), - 'price': random.randint(100, 1000), - } - ) - return rows - - def random_internal_transactions(self, num): - transactions = [] - for i in range(num): - dt = datetime.utcnow().replace(microsecond=0) - transaction = { - 'internal_string': self.random_string(10), - 'internal_number': i, - eve.LAST_UPDATED: dt, - eve.DATE_CREATED: dt, - } - transactions.append(transaction) - return transactions - - def bulk_insert(self): - _db = self.connection[MONGO_DBNAME] - _db.contacts.insert(self.random_contacts(self.known_resource_count)) - _db.contacts.insert(self.random_users(2)) - _db.payments.insert(self.random_payments(10)) - _db.invoices.insert(self.random_invoices(1)) - _db.internal_transactions.insert(self.random_internal_transactions(4)) - _db.products.insert(self.random_products(2)) - self.connection.close() diff --git a/eve/tests/auth.py b/eve/tests/auth.py deleted file mode 100644 index 17b17741e..000000000 --- a/eve/tests/auth.py +++ /dev/null @@ -1,763 +0,0 @@ -# -*- coding: utf-8 -*- -from bson import ObjectId - -import eve -import json -from eve import Eve -from eve.auth import BasicAuth, TokenAuth, HMACAuth -from eve.tests import TestBase -from eve.tests.test_settings import MONGO_DBNAME - - -class ValidBasicAuth(BasicAuth): - def __init__(self): - self.request_auth_value = 'admin' - super(ValidBasicAuth, self).__init__() - - def check_auth(self, username, password, allowed_roles, resource, method): - self.set_request_auth_value(self.request_auth_value) - return username in ('admin', 'alt') and password == 'secret' and \ - ('admin' in allowed_roles if allowed_roles else True) - - -class BadBasicAuth(BasicAuth): - pass - - -class ValidTokenAuth(TokenAuth): - def check_auth(self, token, allowed_roles, resource, method): - return token == 'test_token' and ('admin' in allowed_roles if - allowed_roles else True) - - -class BadTokenAuth(TokenAuth): - pass - - -class ValidHMACAuth(HMACAuth): - def check_auth(self, userid, hmac_hash, headers, data, allowed_roles, - resource, method): - self.set_request_auth_value(userid) - return userid == 'admin' and hmac_hash == 'secret' and \ - ('admin' in allowed_roles if allowed_roles else True) - - -class BadHMACAuth(HMACAuth): - pass - - -class TestBasicAuth(TestBase): - - def setUp(self): - super(TestBasicAuth, self).setUp() - self.app = Eve(settings=self.settings_file, auth=ValidBasicAuth) - self.test_client = self.app.test_client() - self.content_type = ('Content-Type', 'application/json') - self.valid_auth = [('Authorization', 'Basic YWRtaW46c2VjcmV0'), - self.content_type] - self.invalid_auth = [('Authorization', 'Basic IDontThinkSo'), - self.content_type] - self.setUpRoles() - self.app.set_defaults() - - def setUpRoles(self): - for _, schema in self.app.config['DOMAIN'].items(): - schema['allowed_roles'] = ['admin'] - schema['allowed_read_roles'] = ['reader'] - schema['allowed_item_roles'] = ['admin'] - schema['allowed_item_read_roles'] = ['reader'] - schema['allowed_item_write_roles'] = ['editor'] - - def test_custom_auth(self): - self.assertTrue(isinstance(self.app.auth, ValidBasicAuth)) - - def test_restricted_home_access(self): - r = self.test_client.get('/') - self.assert401(r.status_code) - - def test_restricted_resource_access(self): - r = self.test_client.get(self.known_resource_url) - self.assert401(r.status_code) - r = self.test_client.post(self.known_resource_url) - self.assert401(r.status_code) - r = self.test_client.delete(self.known_resource_url) - self.assert401(r.status_code) - - def test_restricted_item_access(self): - r = self.test_client.get(self.item_id_url) - self.assert401(r.status_code) - r = self.test_client.patch(self.item_id_url) - self.assert401(r.status_code) - r = self.test_client.delete(self.item_id_url) - self.assert401(r.status_code) - - def test_authorized_home_access(self): - r = self.test_client.get('/', headers=self.valid_auth) - self.assert200(r.status_code) - - def test_authorized_resource_access(self): - r = self.test_client.get(self.known_resource_url, - headers=self.valid_auth) - self.assert200(r.status_code) - r = self.test_client.post(self.known_resource_url, - data=json.dumps({"k": "value"}), - headers=self.valid_auth) - self.assertValidationErrorStatus(r.status_code) - r = self.test_client.delete(self.known_resource_url, - headers=self.valid_auth) - self.assert204(r.status_code) - - def test_authorized_item_access(self): - r = self.test_client.get(self.item_id_url, headers=self.valid_auth) - self.assert200(r.status_code) - r = self.test_client.patch(self.item_id_url, - data=json.dumps({"k": "value"}), - headers=self.valid_auth) - self.assert428(r.status_code) - r = self.test_client.delete(self.item_id_url, headers=self.valid_auth) - self.assert428(r.status_code) - - def test_authorized_schema_access(self): - self.app.config['SCHEMA_ENDPOINT'] = 'schema' - self.app._init_schema_endpoint() - - r = self.test_client.get('/schema/%s' % self.known_resource, - headers=self.valid_auth) - self.assert200(r.status_code) - - def test_unauthorized_home_access(self): - r = self.test_client.get('/', headers=self.invalid_auth) - self.assert401(r.status_code) - - def test_unauthorized_resource_access(self): - r = self.test_client.get(self.known_resource_url, - headers=self.invalid_auth) - self.assert401(r.status_code) - r = self.test_client.post(self.known_resource_url, - headers=self.invalid_auth) - self.assert401(r.status_code) - r = self.test_client.delete(self.known_resource_url, - headers=self.invalid_auth) - self.assert401(r.status_code) - - def test_unauthorized_item_access(self): - r = self.test_client.get(self.item_id_url, headers=self.invalid_auth) - self.assert401(r.status_code) - r = self.test_client.patch(self.item_id_url, headers=self.invalid_auth) - self.assert401(r.status_code) - r = self.test_client.delete(self.item_id_url, - headers=self.invalid_auth) - self.assert401(r.status_code) - - def test_unauthorized_schema_access(self): - self.app.config['SCHEMA_ENDPOINT'] = 'schema' - self.app._init_schema_endpoint() - - r = self.test_client.get('/schema/%s' % self.known_resource, - headers=self.invalid_auth) - self.assert401(r.status_code) - - def test_home_public_methods(self): - self.app.config['PUBLIC_METHODS'] = ['GET'] - r = self.test_client.get('/') - self.assert200(r.status_code) - self.test_restricted_resource_access() - self.test_restricted_item_access() - - def test_public_methods_resource(self): - self.app.config['PUBLIC_METHODS'] = ['GET'] - domain = self.app.config['DOMAIN'] - for resource, settings in domain.items(): - del(settings['public_methods']) - self.app.set_defaults() - del(domain['peopleinvoices']) - del(domain['peoplerequiredinvoices']) - del(domain['peoplesearches']) - del(domain['internal_transactions']) - del(domain['child_products']) - for resource in domain: - url = self.app.config['URLS'][resource] - r = self.test_client.get(url) - self.assert200(r.status_code) - r = self.test_client.post(url, data={'key1': 'value1'}) - self.assert401or405(r.status_code) - r = self.test_client.delete(url) - self.assert401or405(r.status_code) - self.test_restricted_item_access() - - def test_public_methods_but_locked_resource(self): - self.app.config['PUBLIC_METHODS'] = ['GET'] - domain = self.app.config['DOMAIN'] - for _, settings in domain.items(): - del(settings['public_methods']) - self.app.set_defaults() - domain[self.known_resource]['public_methods'] = [] - r = self.test_client.get(self.known_resource_url) - self.assert401(r.status_code) - - def test_public_methods_but_locked_item(self): - self.app.config['PUBLIC_ITEM_METHODS'] = ['GET'] - domain = self.app.config['DOMAIN'] - for _, settings in domain.items(): - del(settings['public_item_methods']) - self.app.set_defaults() - domain[self.known_resource]['public_item_methods'] = [] - r = self.test_client.get(self.item_id_url) - self.assert401(r.status_code) - - def test_public_methods_item(self): - self.app.config['PUBLIC_ITEM_METHODS'] = ['GET'] - for _, settings in self.app.config['DOMAIN'].items(): - del(settings['public_item_methods']) - self.app.set_defaults() - # we're happy with testing just one client endpoint, but for sake of - # completeness we shold probably test item endpoints for every resource - r = self.test_client.get(self.item_id_url) - self.assert200(r.status_code) - r = self.test_client.patch(self.item_id_url) - self.assert401(r.status_code) - r = self.test_client.delete(self.item_id_url) - self.assert401(r.status_code) - - def test_bad_auth_class(self): - self.app = Eve(settings=self.settings_file, auth=BadBasicAuth) - self.test_client = self.app.test_client() - r = self.test_client.get('/', headers=self.valid_auth) - # will fail because check_auth() is not implemented in the custom class - self.assert500(r.status_code) - - def test_instanced_auth(self): - # tests that the 'auth' argument can also be a class instance. See - # #248. - - # current self.app instance has an instanced auth class already, and it - # is consistent with the super class running the test (Token, HMAC or - # Basic), so we are just going to use it (self.app.auth) on a new Eve - # instance. - - auth = self.app.auth - self.app = Eve(settings=self.settings_file, auth=auth) - self.test_client = self.app.test_client() - r = self.test_client.get('/', headers=self.valid_auth) - self.assert200(r.status_code) - - def test_rfc2617_response(self): - r = self.test_client.get('/') - self.assert401(r.status_code) - self.assertTrue(('WWW-Authenticate', 'Basic realm="%s"' % - eve.__package__) in r.headers.to_wsgi_list()) - - def test_allowed_roles_does_not_change(self): - self.test_client.get(self.known_resource_url) - resource = self.app.config['DOMAIN'][self.known_resource] - self.assertEqual(resource['allowed_roles'], ['admin']) - - def test_allowed_item_roles_does_not_change(self): - self.test_client.get(self.item_id_url) - resource = self.app.config['DOMAIN'][self.known_resource] - self.assertEqual(resource['allowed_item_roles'], ['admin']) - - def test_ALLOWED_ROLES_does_not_change(self): - self.app.config['ALLOWED_ROLES'] = ['admin'] - self.app.config['ALLOWED_READ_ROLES'] = ['reader'] - self.test_client.get('/') - self.assertEqual(self.app.config['ALLOWED_ROLES'], ['admin']) - - -class TestTokenAuth(TestBasicAuth): - def setUp(self): - super(TestTokenAuth, self).setUp() - self.app = Eve(settings=self.settings_file, auth=ValidTokenAuth) - self.test_client = self.app.test_client() - self.valid_auth = [('Authorization', 'Basic dGVzdF90b2tlbjo='), - self.content_type] - self.setUpRoles() - - def test_custom_auth(self): - self.assertTrue(isinstance(self.app.auth, ValidTokenAuth)) - - -class TestBearerTokenAuth(TestTokenAuth): - def setUp(self): - super(TestBearerTokenAuth, self).setUp() - self.valid_auth = [('Authorization', 'Token test_token'), - self.content_type] - - def test_bad_auth_class(self): - self.app = Eve(settings=self.settings_file, auth=BadTokenAuth) - self.test_client = self.app.test_client() - r = self.test_client.get('/', headers=self.valid_auth) - # will fail because check_auth() is not implemented in the custom class - self.assert500(r.status_code) - - -class TestCustomTokenAuth(TestTokenAuth): - def setUp(self): - super(TestCustomTokenAuth, self).setUp() - self.valid_auth = [('Authorization', 'Token test_token'), - self.content_type] - - def test_bad_auth_class(self): - self.app = Eve(settings=self.settings_file, auth=BadTokenAuth) - self.test_client = self.app.test_client() - r = self.test_client.get('/', headers=self.valid_auth) - # will fail because check_auth() is not implemented in the custom class - self.assert500(r.status_code) - - -class TestHMACAuth(TestBasicAuth): - def setUp(self): - super(TestHMACAuth, self).setUp() - self.app = Eve(settings=self.settings_file, auth=ValidHMACAuth) - self.test_client = self.app.test_client() - self.valid_auth = [('Authorization', 'admin:secret'), - self.content_type] - self.setUpRoles() - - def test_custom_auth(self): - self.assertTrue(isinstance(self.app.auth, ValidHMACAuth)) - - def test_bad_auth_class(self): - self.app = Eve(settings=self.settings_file, auth=BadHMACAuth) - self.test_client = self.app.test_client() - r = self.test_client.get('/', headers=self.valid_auth) - # will fail because check_auth() is not implemented in the custom class - self.assert500(r.status_code) - - def test_rfc2617_response(self): - r = self.test_client.get('/') - self.assert401(r.status_code) - - def test_post_resource_hmac_auth(self): - # Test that user restricted access works with HMAC auth. - resource_def = self.app.config['DOMAIN']['restricted'] - resource_def['auth_field'] = 'username' - url = resource_def['url'] - data = {"ref": "0123456789123456789012345"} - - r = self.app.test_client().post(url, data=json.dumps(data), - headers=self.valid_auth, - content_type='application/json') - - # Verify that we can retrieve the same document - r, status = self.parse_response( - self.app.test_client().get(url, headers=self.valid_auth)) - self.assert200(status) - self.assertEqual(len(r['_items']), 1) - self.assertEqual(r['_items'][0]['ref'], data['ref']) - - -class TestResourceAuth(TestBase): - def test_resource_only_auth(self): - # no auth at the API level - self.app = Eve(settings=self.settings_file) - self.test_client = self.app.test_client() - # explicit auth for just one resource - self.app.config['DOMAIN']['contacts']['authentication'] = \ - ValidBasicAuth() - self.app.config['DOMAIN']['empty']['authentication'] = ValidTokenAuth() - self.app.set_defaults() - basic_auth = [('Authorization', 'Basic YWRtaW46c2VjcmV0')] - token_auth = [('Authorization', 'Basic dGVzdF90b2tlbjo=')] - - # 'contacts' endpoints are protected - r = self.test_client.get(self.known_resource_url) - self.assert401(r.status_code) - r = self.test_client.get(self.item_id_url) - self.assert401(r.status_code) - # both with BasicAuth. - _, status = self.parse_response( - self.test_client.get(self.known_resource_url, headers=basic_auth)) - self.assert200(status) - _, status = self.parse_response( - self.test_client.get(self.item_id_url, headers=basic_auth)) - self.assert200(status) - - # 'empty' resource endpoint is also protected - r = self.test_client.get(self.empty_resource_url) - self.assert401(r.status_code) - # but with TokenAuth - r = self.test_client.get(self.empty_resource_url, headers=token_auth) - self.assert200(r.status_code) - - # other resources are not protected - r = self.test_client.get(self.readonly_resource_url) - self.assert200(r.status_code) - - -class TestUserRestrictedAccess(TestBase): - def setUp(self): - super(TestUserRestrictedAccess, self).setUp() - - self.app = Eve(settings=self.settings_file, auth=ValidBasicAuth) - - # using this endpoint since it is a copy of 'contacts' with - # no filter on the datasource - self.url = 'restricted' - self.resource = self.app.config['DOMAIN'][self.url] - self.test_client = self.app.test_client() - - self.valid_auth = [('Authorization', 'Basic YWRtaW46c2VjcmV0')] - self.invalid_auth = [('Authorization', 'Basic IDontThinkSo')] - self.field_name = 'auth_field' - self.data = json.dumps({"ref": "0123456789123456789012345"}) - - for _, settings in self.app.config['DOMAIN'].items(): - settings[self.field_name] = 'username' - - self.resource['public_methods'] = [] - - def test_get(self): - data, status = self.parse_response( - self.test_client.get(self.url, headers=self.valid_auth)) - self.assert200(status) - # no data has been saved by user 'admin' yet, - # so assert we get an empty result set back. - self.assertEqual(len(data['_items']), 0) - - # Add a user belonging to `admin` - new_user = self.random_contacts(1)[0] - new_user['username'] = 'admin' - _db = self.connection[self.app.config['MONGO_DBNAME']] - _db.contacts.insert(new_user) - - # Verify that we can retrieve it - data2, status2 = self.parse_response( - self.test_client.get(self.url, - headers=self.valid_auth)) - self.assert200(status2) - self.assertEqual(len(data2['_items']), 1) - - def test_get_by_auth_field_criteria(self): - """ If we attempt to retrieve an object by the same field - that is in `auth_field`, then the request is /unauthorized/, - and should fail and return 401. - - This test verifies that the `auth_field` does not overwrite - a `client_filter` or url param. - """ - _, status = self.parse_response( - self.test_client.get(self.user_username_url, - headers=self.valid_auth)) - self.assert401(status) - - def test_get_by_auth_field_id(self): - """ To test handling of ObjectIds - """ - # set auth_field to `_id` - self.domain['users'][self.field_name] = \ - self.domain['users']['id_field'] - - _, status = self.parse_response( - self.test_client.get(self.user_id_url, - headers=self.valid_auth)) - self.assert401(status) - - def test_filter_by_auth_field_id(self): - """ To test handling of ObjectIds when using a `where` clause - We need to make sure we *match* an object ID when it is the - same - """ - _id = ObjectId('deadbeefdeadbeefdeadbeef') - resource_def = self.app.config['DOMAIN']['users'] - resource_def['authentication'].request_auth_value = _id - - # set auth_field to `_id` - resource_def[self.field_name] = '_id' - - # Retrieving a /different user/ by id returns 401 - user_url = '/users/' - filter_by_id = 'where=_id==ObjectId("%s")' - filter_query = filter_by_id % self.user_id - - _, status = self.parse_response( - self.test_client.get('%s?%s' % (user_url, filter_query), - headers=self.valid_auth)) - self.assert401(status) - - # Create a user account belonging to admin - new_user = self.random_contacts(1)[0] - new_user['_id'] = _id - new_user['username'] = 'admin' - _db = self.connection[self.app.config['MONGO_DBNAME']] - _db.contacts.insert(new_user) - - # Retrieving /the same/ user by id returns OK - filter_query_2 = filter_by_id % 'deadbeefdeadbeefdeadbeef' - data2, status2 = self.parse_response( - self.test_client.get('%s?%s' % (user_url, filter_query_2), - headers=self.valid_auth)) - self.assert200(status2) - self.assertEqual(len(data2['_items']), 1) - - def test_collection_get_public(self): - """ Test that if GET is in `public_methods` the `auth_field` - criteria is overruled - """ - self.resource['public_methods'].append('GET') - data, status = self.parse_response( - self.test_client.get(self.url)) # no auth - self.assert200(status) - # no data has been saved by user 'admin' yet, - # but we should get all the other results back - self.assertEqual(len(data['_items']), 25) - - def test_item_get_public(self): - """ Test that if GET is in `public_item_methods` the `auth_field` - criteria is overruled - """ - self.resource['public_item_methods'].append('GET') - data, status = self.parse_response( - self.test_client.get(self.item_id_url, - headers=self.valid_auth)) - self.assert200(status) - self.assertEqual(data['_id'], self.item_id) - - def test_post(self): - _, status = self.post() - self.assert201(status) - data, status = self.parse_response( - self.test_client.get(self.url, - headers=self.valid_auth)) - self.assert200(status) - # len of 1 as there are is only 1 doc saved by user - - def test_unique_to_user_on_post(self): - # make the field unique to user, not globally. - self.resource['schema']['ref']['unique'] = False - self.resource['schema']['ref']['unique_to_user'] = True - - # first post as 'admin' is a success. - _, status = self.post() - self.assert201(status) - - # second post as 'admin' fails since value is not unique to user. - _, status = self.post() - self.assert422(status) - - self.resource['authentication'].request_auth_value = 'alt' - # first post as 'alt' succeeds as value is unique to this user. - alt_auth = [('Authorization', 'Basic YWx0OnNlY3JldA==')] - r = self.test_client.post(self.url, - data=self.data, - headers=alt_auth, - content_type='application/json') - - self.assert201(r.status_code) - - # second post as 'alt' fails since value is not unique to user anymore. - r = self.test_client.post(self.url, - data=self.data, - headers=alt_auth, - content_type='application/json') - - # post succeeds since value is unique to 'alt' user - self.assert422(r.status_code) - - def test_post_resource_auth(self): - # Ticket #231. - # Test that user restricted access works fine if there's no global - # level auth, which is set at resource level instead. - - # no global auth. - self.app = Eve(settings=self.settings_file) - - # set auth at resource level instead. - resource_def = self.app.config['DOMAIN'][self.url] - resource_def['authentication'] = ValidBasicAuth - resource_def['auth_field'] = 'username' - - # post with valid auth - must store the document with the correct - # auth_field. - r = self.app.test_client().post(self.url, data=self.data, - headers=self.valid_auth, - content_type='application/json') - _, status = self.parse_response(r) - - # Verify that we can retrieve the same document - data, status = self.parse_response( - self.app.test_client().get(self.url, headers=self.valid_auth)) - self.assert200(status) - self.assertEqual(len(data['_items']), 1) - self.assertEqual(data['_items'][0]['ref'], - json.loads(self.data)['ref']) - - def test_post_bandwidth_saver_off_resource_auth(self): - """ Test that when BANDWIDTH_SAVER is turned off the auth_field is - not exposed in the response payload - """ - self.app.config['BANDWIDTH_SAVER'] = False - r = self.app.test_client().post(self.url, data=self.data, - headers=self.valid_auth, - content_type='application/json') - r, status = self.parse_response(r) - self.assertTrue('username' not in r) - - def test_put(self): - new_ref = "9999999999999999999999999" - changes = json.dumps({"ref": new_ref}) - - # post document - data, status = self.post() - - # retrieve document metadata - url = '%s/%s' % (self.url, data['_id']) - response = self.test_client.get(url, headers=self.valid_auth) - etag = response.headers['ETag'] - - # perform put - headers = [('If-Match', etag), self.valid_auth[0]] - response, status = self.parse_response( - self.test_client.put(url, data=json.dumps(changes), - headers=headers, - content_type='application/json')) - self.assert200(status) - - # document still accessible with same auth - data, status = self.parse_response( - self.test_client.get(url, headers=self.valid_auth)) - self.assert200(status) - self.assertEqual(data['ref'], new_ref) - - def test_put_resource_auth(self): - # no global auth. - self.app = Eve(settings=self.settings_file) - - # set auth at resource level instead. - resource_def = self.app.config['DOMAIN'][self.url] - resource_def['authentication'] = ValidBasicAuth - resource_def['auth_field'] = 'username' - - # post - r = self.app.test_client().post(self.url, data=self.data, - headers=self.valid_auth, - content_type='application/json') - data, status = self.parse_response(r) - - # retrieve document metadata - url = '%s/%s' % (self.url, data['_id']) - response = self.app.test_client().get(url, headers=self.valid_auth) - etag = response.headers['ETag'] - - new_ref = "9999999999999999999999999" - changes = json.dumps({"ref": new_ref}) - - # put - headers = [('If-Match', etag), self.valid_auth[0]] - response, status = self.parse_response( - self.app.test_client().put(url, data=json.dumps(changes), - headers=headers, - content_type='application/json')) - self.assert200(status) - - # document still accessible with same auth - data, status = self.parse_response( - self.app.test_client().get(url, headers=self.valid_auth)) - self.assert200(status) - self.assertEqual(data['ref'], new_ref) - - def test_put_bandwidth_saver_off_resource_auth(self): - """ Test that when BANDWIDTH_SAVER is turned off the auth_field is - not exposed in the response payload - """ - self.app.config['BANDWIDTH_SAVER'] = False - - new_ref = "9999999999999999999999999" - changes = json.dumps({"ref": new_ref}) - - # post document - data, status = self.post() - - url = '%s/%s' % (self.url, data['_id']) - - # perform put - headers = [('If-Match', data['_etag']), self.valid_auth[0]] - response, status = self.parse_response( - self.test_client.put(url, data=json.dumps(changes), - headers=headers, - content_type='application/json')) - self.assertTrue('username' not in response) - - def test_patch(self): - new_ref = "9999999999999999999999999" - changes = json.dumps({"ref": new_ref}) - data, status = self.post() - url = '%s/%s' % (self.url, data['_id']) - response = self.test_client.get(url, headers=self.valid_auth) - etag = response.headers['ETag'] - headers = [('If-Match', etag), self.valid_auth[0]] - response, status = self.parse_response( - self.test_client.patch(url, data=json.dumps(changes), - headers=headers, - content_type='application/json')) - self.assert200(status) - - data, status = self.parse_response( - self.test_client.get(url, headers=self.valid_auth)) - self.assert200(status) - self.assertEqual(data['ref'], new_ref) - - def test_delete(self): - _db = self.connection[MONGO_DBNAME] - - # make sure that other documents in the collections are untouched. - cursor = _db.contacts.find() - docs_num = cursor.count() - - _, _ = self.post() - - # after the post we only get back 1 document as it's the only one we - # inserted directly (others are filtered out). - response, status = self.parse_response( - self.test_client.get(self.url, headers=self.valid_auth)) - self.assert200(status) - self.assertEqual(len(response[self.app.config['ITEMS']]), 1) - - # delete the document we just inserted - response, status = self.parse_response( - self.test_client.delete(self.url, headers=self.valid_auth)) - self.assert204(status) - - # we now get an empty items list (other documents in collection are - # filtered by auth). - response, status = self.parse_response( - self.test_client.get(self.url, headers=self.valid_auth)) - self.assert200(status) - # if it's a dict, we only got 1 item back which is expected - self.assertEqual(len(response[self.app.config['ITEMS']]), 0) - - # make sure no other document has been deleted. - cursor = _db.contacts.find() - self.assertEqual(cursor.count(), docs_num) - - def test_delete_item(self): - _db = self.connection[MONGO_DBNAME] - - # make sure that other documents in the collections are untouched. - cursor = _db.contacts.find() - docs_num = cursor.count() - - data, _ = self.post() - - # get back the document with its new etag - url = '%s/%s' % (self.url, data['_id']) - response = self.test_client.get(url, headers=self.valid_auth) - etag = response.headers['ETag'] - headers = [('If-Match', etag), - ('Authorization', 'Basic YWRtaW46c2VjcmV0')] - - # delete the document - response, status = self.parse_response( - self.test_client.delete(url, headers=headers)) - self.assert204(status) - - # make sure no other document has been deleted. - cursor = _db.contacts.find() - self.assertEqual(cursor.count(), docs_num) - - def post(self): - r = self.test_client.post(self.url, - data=self.data, - headers=self.valid_auth, - content_type='application/json') - return self.parse_response(r) diff --git a/eve/tests/config.py b/eve/tests/config.py deleted file mode 100644 index d5dcd4bae..000000000 --- a/eve/tests/config.py +++ /dev/null @@ -1,575 +0,0 @@ -# -*- coding: utf-8 -*- - -import eve -import os -from eve.flaskapp import RegexConverter -from eve.flaskapp import Eve -from eve.io.base import DataLayer -from eve.tests import TestBase -from eve.exceptions import ConfigException, SchemaException -from eve.io.mongo import Mongo, Validator - - -class TestConfig(TestBase): - def test_allow_unknown_with_soft_delete(self): - my_settings = { - 'ALLOW_UNKNOWN': True, - 'SOFT_DELETE': True, - 'DOMAIN': {'contacts': {}} - } - try: - self.app = Eve(settings=my_settings) - except TypeError: - self.fail("ALLOW_UNKNOWN and SOFT_DELETE enabled should not cause " - "a crash.") - - def test_default_import_name(self): - self.assertEqual(self.app.import_name, eve.__package__) - - def test_custom_import_name(self): - self.app = Eve('unittest', settings=self.settings_file) - self.assertEqual(self.app.import_name, 'unittest') - - def test_custom_kwargs(self): - self.app = Eve('unittest', static_folder='/', - settings=self.settings_file) - self.assertEqual(self.app.static_folder, '/') - - def test_regexconverter(self): - regex_converter = self.app.url_map.converters.get('regex') - self.assertEqual(regex_converter, RegexConverter) - - def test_default_validator(self): - self.assertEqual(self.app.validator, Validator) - - def test_default_datalayer(self): - self.assertEqual(type(self.app.data), Mongo) - - def test_default_settings(self): - self.assertEqual(self.app.settings, self.settings_file) - - # TODO add tests for other global default values - self.assertEqual(self.app.config['RATE_LIMIT_GET'], None) - self.assertEqual(self.app.config['RATE_LIMIT_POST'], None) - self.assertEqual(self.app.config['RATE_LIMIT_PATCH'], None) - self.assertEqual(self.app.config['RATE_LIMIT_DELETE'], None) - - self.assertEqual(self.app.config['MONGO_HOST'], 'localhost') - self.assertEqual(self.app.config['MONGO_PORT'], 27017) - self.assertEqual(self.app.config['MONGO_QUERY_BLACKLIST'], ['$where', - '$regex']) - self.assertEqual(self.app.config['MONGO_WRITE_CONCERN'], {'w': 1}) - self.assertEqual(self.app.config['ISSUES'], '_issues') - - self.assertEqual(self.app.config['OPLOG'], False) - self.assertEqual(self.app.config['OPLOG_NAME'], 'oplog') - self.assertEqual(self.app.config['OPLOG_ENDPOINT'], None) - self.assertEqual(self.app.config['OPLOG_AUDIT'], True) - self.assertEqual(self.app.config['OPLOG_METHODS'], ['DELETE', - 'POST', - 'PATCH', - 'PUT']) - self.assertEqual(self.app.config['OPLOG_CHANGE_METHODS'], ['DELETE', - 'PATCH', - 'PUT']) - self.assertEqual(self.app.config['QUERY_WHERE'], 'where') - self.assertEqual(self.app.config['QUERY_PROJECTION'], 'projection') - self.assertEqual(self.app.config['QUERY_SORT'], 'sort') - self.assertEqual(self.app.config['QUERY_PAGE'], 'page') - self.assertEqual(self.app.config['QUERY_MAX_RESULTS'], 'max_results') - self.assertEqual(self.app.config['QUERY_EMBEDDED'], 'embedded') - self.assertEqual(self.app.config['QUERY_AGGREGATION'], 'aggregate') - - self.assertEqual(self.app.config['JSON_SORT_KEYS'], False) - self.assertEqual(self.app.config['SOFT_DELETE'], False) - self.assertEqual(self.app.config['DELETED'], '_deleted') - self.assertEqual(self.app.config['SHOW_DELETED_PARAM'], 'show_deleted') - self.assertEqual(self.app.config['STANDARD_ERRORS'], - [400, 401, 404, 405, 406, 409, 410, 412, 422, 428]) - self.assertEqual(self.app.config['UPSERT_ON_PUT'], True) - - def test_settings_as_dict(self): - my_settings = {'API_VERSION': 'override!', 'DOMAIN': {'contacts': {}}} - self.app = Eve(settings=my_settings) - self.assertEqual(self.app.config['API_VERSION'], 'override!') - # did not reset other defaults - self.assertEqual(self.app.config['MONGO_WRITE_CONCERN'], {'w': 1}) - - def test_unexisting_env_config(self): - env = os.environ - try: - os.environ = {'EVE_SETTINGS': 'an_unexisting_pyfile.py'} - self.assertRaises(IOError, Eve) - finally: - os.environ = env - - def test_custom_validator(self): - class MyTestValidator(Validator): - pass - self.app = Eve(validator=MyTestValidator, - settings=self.settings_file) - self.assertEqual(self.app.validator, MyTestValidator) - - def test_custom_datalayer(self): - class MyTestDataLayer(DataLayer): - def init_app(self, app): - pass - self.app = Eve(data=MyTestDataLayer, settings=self.settings_file) - self.assertEqual(type(self.app.data), MyTestDataLayer) - - def test_validate_domain_struct(self): - del self.app.config['DOMAIN'] - self.assertValidateConfigFailure('missing') - - self.app.config['DOMAIN'] = [] - self.assertValidateConfigFailure('must be a dict') - - self.app.config['DOMAIN'] = {} - self.assertValidateConfigSuccess() - - def test_validate_resource_methods(self): - self.app.config['RESOURCE_METHODS'] = ['PUT', 'GET', 'DELETE', 'POST'] - self.assertValidateConfigFailure('PUT') - - def test_validate_item_methods(self): - self.app.config['ITEM_METHODS'] = ['PUT', 'GET', 'POST', 'DELETE'] - self.assertValidateConfigFailure(['POST', 'PUT']) - - def test_validate_schema_methods(self): - test = { - 'resource_methods': ['PUT', 'GET', 'DELETE', 'POST'], - } - self.app.config['DOMAIN']['test_resource'] = test - self.assertValidateConfigFailure('PUT') - - def test_validate_schema_item_methods(self): - test = { - 'resource_methods': ['GET'], - 'item_methods': ['POST'], - } - self.app.config['DOMAIN']['test_resource'] = test - self.assertValidateConfigFailure('PUT') - - def test_validate_datecreated_in_schema(self): - self.assertUnallowedField(eve.DATE_CREATED) - - def test_validate_lastupdated_in_schema(self): - self.assertUnallowedField(eve.LAST_UPDATED) - - def assertUnallowedField(self, field, field_type='datetime'): - self.domain.clear() - schema = {field: {'type': field_type}} - self.domain['resource'] = {'schema': schema} - self.app.set_defaults() - self.assertValidateSchemaFailure('resource', schema, field) - - def test_validate_schema(self): - # lack of 'collection' key for 'data_collection' rule - schema = self.domain['invoices']['schema'] - del(schema['person']['data_relation']['resource']) - self.assertValidateSchemaFailure('invoices', schema, 'resource') - - def test_validate_invalid_field_names(self): - schema = self.domain['invoices']['schema'] - schema['te$t'] = {'type': 'string'} - self.assertValidateSchemaFailure('invoices', schema, 'te$t') - del(schema['te$t']) - - schema['te.t'] = {'type': 'string'} - self.assertValidateSchemaFailure('invoices', schema, 'te.t') - del(schema['te.t']) - - schema['test_a_dict_schema'] = { - 'type': 'dict', - 'schema': {'te$t': {'type': 'string'}} - } - self.assertValidateSchemaFailure('invoices', schema, 'te$t') - - schema['test_a_dict_schema']['schema'] = {'te.t': {'type': 'string'}} - self.assertValidateSchemaFailure('invoices', schema, 'te.t') - - def test_set_schema_defaults(self): - # default data_relation field value - schema = self.domain['invoices']['schema'] - data_relation = schema['person']['data_relation'] - self.assertTrue('field' in data_relation) - self.assertEqual(data_relation['field'], - self.domain['contacts']['id_field']) - id_field = self.domain['invoices']['id_field'] - self.assertTrue(id_field in schema) - self.assertEqual(schema[id_field], {'type': 'objectid'}) - - def test_set_defaults(self): - self.domain.clear() - resource = 'plurals' - self.domain[resource] = {} - self.app.set_defaults() - self._test_defaults_for_resource(resource) - settings = self.domain[resource] - self.assertEqual(len(settings['schema']), 1) - - def _test_defaults_for_resource(self, resource): - settings = self.domain[resource] - self.assertEqual(settings['url'], resource) - self.assertEqual(settings['internal_resource'], - self.app.config['INTERNAL_RESOURCE']) - self.assertEqual(settings['resource_methods'], - self.app.config['RESOURCE_METHODS']) - self.assertEqual(settings['public_methods'], - self.app.config['PUBLIC_METHODS']) - self.assertEqual(settings['allowed_roles'], - self.app.config['ALLOWED_ROLES']) - self.assertEqual(settings['allowed_read_roles'], - self.app.config['ALLOWED_READ_ROLES']) - self.assertEqual(settings['allowed_write_roles'], - self.app.config['ALLOWED_WRITE_ROLES']) - self.assertEqual(settings['cache_control'], - self.app.config['CACHE_CONTROL']) - self.assertEqual(settings['cache_expires'], - self.app.config['CACHE_EXPIRES']) - self.assertEqual(settings['item_methods'], - self.app.config['ITEM_METHODS']) - self.assertEqual(settings['public_item_methods'], - self.app.config['PUBLIC_ITEM_METHODS']) - self.assertEqual(settings['allowed_item_roles'], - self.app.config['ALLOWED_ITEM_ROLES']) - self.assertEqual(settings['allowed_item_read_roles'], - self.app.config['ALLOWED_ITEM_READ_ROLES']) - self.assertEqual(settings['allowed_item_write_roles'], - self.app.config['ALLOWED_ITEM_WRITE_ROLES']) - self.assertEqual(settings['item_lookup'], - self.app.config['ITEM_LOOKUP']) - self.assertEqual(settings['item_lookup_field'], - self.app.config['ITEM_LOOKUP_FIELD']) - self.assertEqual(settings['item_url'], - self.app.config['ITEM_URL']) - self.assertEqual(settings['item_title'], - resource.rstrip('s').capitalize()) - self.assertEqual(settings['allowed_filters'], - self.app.config['ALLOWED_FILTERS']) - self.assertEqual(settings['projection'], self.app.config['PROJECTION']) - self.assertEqual(settings['versioning'], self.app.config['VERSIONING']) - self.assertEqual(settings['soft_delete'], - self.app.config['SOFT_DELETE']) - self.assertEqual(settings['sorting'], self.app.config['SORTING']) - self.assertEqual(settings['embedding'], self.app.config['EMBEDDING']) - self.assertEqual(settings['pagination'], self.app.config['PAGINATION']) - self.assertEqual(settings['auth_field'], - self.app.config['AUTH_FIELD']) - self.assertEqual(settings['allow_unknown'], - self.app.config['ALLOW_UNKNOWN']) - self.assertEqual(settings['transparent_schema_rules'], - self.app.config['TRANSPARENT_SCHEMA_RULES']) - self.assertEqual(settings['extra_response_fields'], - self.app.config['EXTRA_RESPONSE_FIELDS']) - self.assertEqual(settings['mongo_write_concern'], - self.app.config['MONGO_WRITE_CONCERN']) - self.assertEqual(settings['resource_title'], settings['url']) - - self.assertNotEqual(settings['schema'], None) - self.assertEqual(type(settings['schema']), dict) - self.assertEqual(settings['etag_ignore_fields'], None) - - def test_datasource(self): - self._test_datasource_for_resource('invoices') - - def _test_datasource_for_resource(self, resource): - datasource = self.domain[resource]['datasource'] - schema = self.domain[resource]['schema'] - compare = [key for key in datasource['projection'] if key in schema] - compare.extend([self.domain[resource]['id_field'], - self.app.config['LAST_UPDATED'], - self.app.config['DATE_CREATED'], - self.app.config['ETAG']]) - - self.assertEqual(datasource['projection'], - dict((field, 1) for (field) in compare)) - self.assertEqual(datasource['source'], resource) - self.assertEqual(datasource['filter'], None) - - self.assertEqual(datasource['aggregation'], None) - - def test_validate_roles(self): - for resource in self.domain: - self.assertValidateRoles(resource, 'allowed_roles') - self.assertValidateRoles(resource, 'allowed_read_roles') - self.assertValidateRoles(resource, 'allowed_write_roles') - self.assertValidateRoles(resource, 'allowed_item_roles') - self.assertValidateRoles(resource, 'allowed_item_read_roles') - self.assertValidateRoles(resource, 'allowed_item_write_roles') - - def assertValidateRoles(self, resource, directive): - prev = self.domain[resource][directive] - self.domain[resource][directive] = 'admin' - self.assertValidateConfigFailure(directive) - self.domain[resource][directive] = [] - self.assertValidateConfigSuccess() - self.domain[resource][directive] = ['admin', 'dev'] - self.assertValidateConfigSuccess() - self.domain[resource][directive] = None - self.assertValidateConfigFailure(directive) - self.domain[resource][directive] = prev - - def assertValidateConfigSuccess(self): - try: - self.app.validate_domain_struct() - self.app.validate_config() - except ConfigException as e: - self.fail('ConfigException not expected: %s' % e) - - def assertValidateConfigFailure(self, expected): - try: - self.app.validate_domain_struct() - self.app.validate_config() - except ConfigException as e: - if isinstance(expected, str): - expected = [expected] - for exp in expected: - self.assertTrue(exp.lower() in str(e).lower()) - else: - self.fail("ConfigException expected but not raised.") - - def assertValidateSchemaFailure(self, resource, schema, expected): - try: - self.app.validate_schema(resource, schema) - except SchemaException as e: - self.assertTrue(expected.lower() in str(e).lower()) - else: - self.fail("SchemaException expected but not raised.") - - def test_schema_defaults(self): - self.domain.clear() - self.domain['resource'] = { - 'schema': { - 'title': { - 'type': 'string', - 'default': 'Mr.', - }, - 'price': { - 'type': 'integer', - 'default': 100 - }, - } - } - self.app.set_defaults() - settings = self.domain['resource'] - self.assertEqual({'title': 'Mr.', 'price': 100}, settings['defaults']) - - def test_url_helpers(self): - self.assertNotEqual(self.app.config.get('URLS'), None) - self.assertEqual(type(self.app.config['URLS']), dict) - - self.assertNotEqual(self.app.config.get('SOURCES'), None) - self.assertEqual(type(self.app.config['SOURCES']), dict) - - del(self.domain['internal_transactions']) - for resource, settings in self.domain.items(): - self.assertEqual(settings['datasource'], - self.app.config['SOURCES'][resource]) - - def test_pretty_resource_urls(self): - """ test that regexes are stripped out of urls and #466 is fixed. """ - resource_url = self.app.config['URLS']['peopleinvoices'] - pretty_url = 'users//invoices' - self.assertEqual(resource_url, pretty_url) - resource_url = self.app.config['URLS']['peoplesearches'] - pretty_url = 'users//saved_searches' - self.assertEqual(resource_url, pretty_url) - - def test_url_rules(self): - map_adapter = self.app.url_map.bind('') - - del(self.domain['peopleinvoices']) - del(self.domain['peoplerequiredinvoices']) - del(self.domain['peoplesearches']) - del(self.domain['internal_transactions']) - del(self.domain['child_products']) - for _, settings in self.domain.items(): - for method in settings['resource_methods']: - self.assertTrue(map_adapter.test('/%s/' % settings['url'], - method)) - - # TODO test item endpoints as well. gonna be tricky since - # we have to reverse regexes here. will be fun. - - def test_register_resource(self): - resource = 'resource' - settings = { - 'schema': { - 'title': { - 'type': 'string', - 'default': 'Mr.', - }, - 'price': { - 'type': 'integer', - 'default': 100 - }, - } - } - self.app.register_resource(resource, settings) - self._test_defaults_for_resource(resource) - self._test_datasource_for_resource(resource) - self.test_validate_roles() - - def test_auth_field_as_idfield(self): - resource = 'resource' - settings = { - 'auth_field': self.app.config['ID_FIELD'], - } - self.assertRaises(ConfigException, self.app.register_resource, - resource, settings) - - def test_auth_field_as_custom_idfield(self): - resource = 'resource' - settings = { - 'schema': { - 'id': {'type': 'string'} - }, - 'id_field': 'id', - 'auth_field': 'id' - } - self.assertRaises(ConfigException, self.app.register_resource, - resource, settings) - - def test_oplog_config(self): - - # if OPLOG_ENDPOINT is eanbled the endoint is included with the domain - self.app.config['OPLOG_ENDPOINT'] = 'oplog' - self.app._init_oplog() - self.assertOplog('oplog', 'oplog') - del(self.domain['oplog']) - - # OPLOG can be also with a custom name (which will be used - # as the collection/table name on the db) - oplog = 'custom' - self.app.config['OPLOG_NAME'] = oplog - self.app._init_oplog() - self.assertOplog(oplog, 'oplog') - del(self.domain[oplog]) - - # oplog can be defined as a regular API endpoint, with a couple caveats - self.domain['oplog'] = { - 'resource_methods': ['POST', 'DELETE'], # not allowed - 'resource_items': ['PATCH', 'PUT'], # not allowed - 'url': 'custom_url', - 'datasource': {'source': 'customsource'} - } - self.app.config['OPLOG_NAME'] = 'oplog' - settings = self.domain['oplog'] - self.app._init_oplog() - - # endpoint is always read-only - self.assertEqual(settings['resource_methods'], ['GET']) - self.assertEqual(settings['item_methods'], ['GET']) - # other settings are customizable - self.assertEqual(settings['url'], 'custom_url') - self.assertEqual(settings['datasource']['source'], 'customsource') - - def assertOplog(self, key, endpoint): - self.assertTrue(key in self.domain) - - settings = self.domain[key] - self.assertEqual(settings['resource_methods'], ['GET']) - self.assertEqual(settings['item_methods'], ['GET']) - self.assertEqual(settings['url'], endpoint) - self.assertEqual(settings['datasource']['source'], key) - - def test_create_indexes(self): - # prepare a specific schema with mongo indexes declared - # along with the schema. - settings = { - 'schema': { - 'name': {'type': 'string'}, - 'other_field': {'type': 'string'}, - 'lat_long': {'type': 'list'} - }, - 'versioning': True, - 'mongo_indexes': { - 'name': [('name', 1)], - 'composed': [('name', 1), ('other_field', 1)], - 'arguments': ([('lat_long', "2d")], {"sparce": True}) - } - } - self.app.register_resource('mongodb_features', settings) - - # check that the indexes are there as a part of the resource - # settings - self.assertEqual( - self.app.config['DOMAIN']['mongodb_features']['mongo_indexes'], - settings['mongo_indexes'] - ) - - # check that the indexes were created - from pymongo import MongoClient - db_name = self.app.config['MONGO_DBNAME'] - - db = MongoClient()[db_name] - for coll in [db['mongodb_features'], db['mongodb_features_versions']]: - indexes = coll.index_information() - - # at least there is an index for the _id field plus the indexes - # created by the resource of this test - self.assertTrue(len(indexes) > len(settings['mongo_indexes'])) - - # check each one, fields involved and arguments given - for key, value in settings['mongo_indexes'].items(): - if isinstance(value, tuple): - fields, args = value - else: - fields = value - args = None - - self.assertTrue(key in indexes) - self.assertEqual(indexes[key]['key'], fields) - - for arg in args or (): - self.assertTrue(arg in indexes[key]) - self.assertEqual(args[arg], indexes[key][arg]) - - def test_custom_error_handlers(self): - """ Test that the standard, custom error handler is registered for - supported error codes. - """ - codes = self.app.config['STANDARD_ERRORS'] - - # http://flask.pocoo.org/docs/0.10/api/#flask.Flask.error_handler_spec - handlers = self.app.error_handler_spec[None] - - challenge = lambda code: self.assertTrue(code in handlers) # noqa - map(challenge, codes) - - def test_mongodb_settings(self): - # Create custom app with mongodb settings. - settings = { - 'DOMAIN': {'contacts': {}}, - 'MONGO_OPTIONS': { - 'connect': False - } - } - app = Eve(settings=settings) - # Check if settings are set. - self.assertEqual( - app.config['MONGO_OPTIONS']['connect'], - app.config['MONGO_CONNECT'] - ) - # Prepare a specific schema with mongo specific settings. - settings = { - 'schema': { - 'name': {'type': 'string'}, - }, - 'MONGO_OPTIONS': { - 'connect': False - } - } - self.app.register_resource('mongodb_settings', settings) - # check that settings are set. - resource_settings = self.app.config['DOMAIN']['mongodb_settings'] - self.assertEqual( - resource_settings['MONGO_OPTIONS'], - settings['MONGO_OPTIONS'] - ) - # check that settings are set. - self.assertEqual( - resource_settings['MONGO_OPTIONS']['connect'], - settings['MONGO_OPTIONS']['connect'] - ) diff --git a/eve/tests/default_values.py b/eve/tests/default_values.py deleted file mode 100644 index 4da7a97aa..000000000 --- a/eve/tests/default_values.py +++ /dev/null @@ -1,243 +0,0 @@ -import unittest - -from eve.defaults import build_defaults, resolve_default_values - - -class TestBuildDefaults(unittest.TestCase): - def test_schemaless_dict(self): - schema = { - "address": { - 'type': 'dict' - } - } - self.assertEqual({}, build_defaults(schema)) - - def test_simple(self): - schema = { - "name": {'type': 'string'}, - "email": {'type': 'string', 'default': "no@example.com"} - } - res = build_defaults(schema) - self.assertEqual({'email': 'no@example.com'}, res) - - def test_nested_one_level(self): - schema = { - "address": { - 'type': 'dict', - 'schema': { - 'street': {'type': 'string'}, - 'country': {'type': 'string', 'default': 'wonderland'} - } - } - } - res = build_defaults(schema) - self.assertEqual({'address': {'country': 'wonderland'}}, res) - - def test_empty_defaults_multiple_level(self): - schema = { - 'subscription': { - 'type': 'dict', - 'schema': { - 'type': {'type': 'string'}, - 'when': { - 'type': 'dict', - 'schema': { - 'timestamp': {'type': 'int'}, - 'repr': {'type': 'string'} - } - } - } - } - } - res = build_defaults(schema) - self.assertEqual({}, res) - - def test_nested_multilevel(self): - schema = { - "subscription": { - 'type': 'dict', - 'schema': { - 'type': {'type': 'string'}, - 'when': { - 'type': 'dict', - 'schema': { - 'timestamp': {'type': 'int', 'default': 0}, - 'repr': {'type': 'string', 'default': '0'} - } - } - } - } - } - res = build_defaults(schema) - self.assertEqual( - {'subscription': {'when': {'timestamp': 0, 'repr': '0'}}}, - res) - - def test_default_in_list_schema(self): - schema = { - "one": { - 'type': 'list', - 'schema': { - 'type': 'dict', - 'schema': { - 'title': { - 'type': 'string', - 'default': 'M.' - } - } - } - }, - "two": { - 'type': 'list', - 'schema': { - 'type': 'dict', - 'schema': { - 'name': {'type': 'string'} - } - } - } - } - res = build_defaults(schema) - self.assertEqual({"one": [{'title': 'M.'}]}, res) - - def test_default_in_list_without_schema(self): - schema = { - "one": { - 'type': 'list', - 'schema': { - 'type': 'string', - 'default': 'item' - } - } - } - res = build_defaults(schema) - self.assertEqual({"one": ['item']}, res) - - def test_lists_of_lists_with_default(self): - schema = { - 'twisting': { - 'type': 'list', # list of groups - 'required': True, - 'schema': { - 'type': 'list', # list of signals (in one group) - 'schema': { - 'type': 'string', - 'default': 'listoflist', - } - } - } - } - res = build_defaults(schema) - self.assertEqual({'twisting': [['listoflist']]}, res) - - def test_lists_of_lists_without_default(self): - schema = { - 'twisting': { - 'type': 'list', # list of groups - 'required': True, - 'schema': { - 'type': 'list', # list of signals (in one group) - 'schema': { - 'type': 'ObjectId', - 'required': True - } - } - } - } - res = build_defaults(schema) - self.assertEqual({}, res) - - def test_lists_of_lists_with_a_dict(self): - schema = { - 'twisting': { - 'type': 'list', # list of groups - 'required': True, - 'schema': { - 'type': 'list', # list of signals (in one group) - 'schema': { - 'type': 'dict', - 'schema': { - 'name': { - 'type': 'string', - 'default': 'me' - } - }, - } - } - } - } - res = build_defaults(schema) - self.assertEqual({'twisting': [[{'name': 'me'}]]}, res) - - -class TestResolveDefaultValues(unittest.TestCase): - def test_one_level(self): - document = {'name': 'john'} - defaults = {'email': 'noemail'} - resolve_default_values(document, defaults) - self.assertEqual({'name': 'john', 'email': 'noemail'}, document) - - def test_multilevel(self): - document = {'name': 'myname', 'one': {'hey': 'jude'}} - defaults = {'one': {'two': {'three': 'banana'}}} - resolve_default_values(document, defaults) - expected = { - 'name': 'myname', - 'one': { - 'hey': 'jude', - 'two': {'three': 'banana'} - } - } - self.assertEqual(expected, document) - - def test_value_instead_of_dict(self): - document = {'name': 'john'} - defaults = {'name': {'first': 'john'}} - resolve_default_values(document, defaults) - self.assertEqual(document, defaults) - - def test_lists(self): - document = {"one": [{"name": "john"}, {}]} - defaults = {"one": [{"title": "M."}]} - resolve_default_values(document, defaults) - expected = {"one": [ - {"name": "john", "title": "M."}, - {"title": "M."}]} - self.assertEqual(expected, document) - - def test_list_of_list_single_value(self): - document = {'one': [[], []]} - defaults = {'one': [['listoflist']]} - resolve_default_values(document, defaults) - # This functionality is not supported, no change in the document - expected = {'one': [[], []]} - assert expected == document - - def test_list_empty_list_as_default(self): - # test that a default value of [] for a list does not causes IndexError - # (#417). - document = {'a': ['b']} - defaults = {'a': []} - resolve_default_values(document, defaults) - expected = {'a': ['b']} - assert expected == document - - def test_list_of_strings_as_default(self): - document = {} - defaults = {'a': ['b']} - resolve_default_values(document, defaults) - expected = {'a': ['b']} - assert expected == document - # overwrite defaults - document = {'a': ['c', 'd']} - defaults = {'a': ['b']} - resolve_default_values(document, defaults) - expected = {'a': ['c', 'd']} - assert expected == document - - def test_list_of_list_dict_value(self): - document = {'one': [[{}], [{}]]} - defaults = {'one': [[{'name': 'banana'}]]} - resolve_default_values(document, defaults) - expected = {'one': [[{'name': 'banana'}], [{'name': 'banana'}]]} - assert expected == document diff --git a/eve/tests/io/media.py b/eve/tests/io/media.py deleted file mode 100644 index 1380bb2fa..000000000 --- a/eve/tests/io/media.py +++ /dev/null @@ -1,398 +0,0 @@ -from io import BytesIO -from unittest import TestCase -from eve.io.media import MediaStorage -from eve.io.mongo import GridFSMediaStorage -from eve.tests import TestBase, MONGO_DBNAME -from eve import STATUS_OK, STATUS, STATUS_ERR, ISSUES, ETAG -import base64 -from bson import ObjectId - - -class TestMediaStorage(TestCase): - def test_base_media_storage(self): - a = MediaStorage() - self.assertEqual(a.app, None) - - a = MediaStorage("hello") - self.assertEqual(a.app, "hello") - - self.assertRaises(NotImplementedError, a.get, 1) - self.assertRaises(NotImplementedError, a.put, "clean", "filename") - self.assertRaises(NotImplementedError, a.delete, 1) - self.assertRaises(NotImplementedError, a.exists, 1) - - -class TestGridFSMediaStorage(TestBase): - def setUp(self): - super(TestGridFSMediaStorage, self).setUp() - self.url = self.known_resource_url - self.resource = self.known_resource - self.headers = [('Content-Type', 'multipart/form-data')] - self.id_field = self.domain[self.resource]['id_field'] - self.test_field, self.test_value = 'ref', "1234567890123456789054321" - # we want an explicit binary as Py3 encodestring() expects binaries. - self.clean = b'my file contents' - # encodedstring will raise a DeprecationWarning under Python3.3, but - # the alternative encodebytes is not available in Python 2. - self.encoded = base64.encodestring(self.clean).decode('utf-8') - - def test_gridfs_media_storage_errors(self): - self.assertRaises(TypeError, GridFSMediaStorage) - self.assertRaises(TypeError, GridFSMediaStorage, "hello") - - def test_gridfs_media_storage_post(self): - # send something different than a file and get an error back - data = {'media': 'not a file'} - r, s = self.parse_response( - self.test_client.post(self.url, data=data, headers=self.headers)) - self.assertEqual(STATUS_ERR, r[STATUS]) - - # validates media fields - self.assertTrue('file was expected' in r[ISSUES]['media']) - # also validates ordinary fields - self.assertTrue('required' in r[ISSUES][self.test_field]) - - r, s = self._post() - self.assertEqual(STATUS_OK, r[STATUS]) - - # compare original and returned data - _id = r[self.id_field] - self.assertMediaField(_id, self.encoded, self.clean) - - # GET the file at the resource endpoint - where = 'where={"%s": "%s"}' % (self.id_field, _id) - r, s = self.parse_response( - self.test_client.get('%s?%s' % (self.url, where))) - self.assertEqual(len(r['_items']), 1) - returned = r['_items'][0]['media'] - - # returned value is a base64 encoded string - self.assertEqual(returned, self.encoded) - - # which decodes to the original clean - self.assertEqual(base64.decodestring(returned.encode()), self.clean) - - def test_gridfs_media_storage_post_excluded_file_in_result(self): - # send something different than a file and get an error back - data = {'media': 'not a file'} - r, s = self.parse_response( - self.test_client.post(self.url, data=data, headers=self.headers)) - self.assertEqual(STATUS_ERR, r[STATUS]) - - # validates media fields - self.assertTrue('file was expected' in r[ISSUES]['media']) - # also validates ordinary fields - self.assertTrue('required' in r[ISSUES][self.test_field]) - - r, s = self._post() - self.assertEqual(STATUS_OK, r[STATUS]) - - self.app.config['RETURN_MEDIA_AS_BASE64_STRING'] = False - # compare original and returned data - _id = r[self.id_field] - - # GET the file at the resource endpoint - where = 'where={"%s": "%s"}' % (self.id_field, _id) - r, s = self.parse_response( - self.test_client.get('%s?%s' % (self.url, where))) - self.assertEqual(len(r['_items']), 1) - returned = r['_items'][0]['media'] - - # returned value is a base64 encoded string - self.assertEqual(returned, None) - - def test_gridfs_media_storage_post_extended(self): - r, s = self._post() - self.assertEqual(STATUS_OK, r[STATUS]) - - # request extended format file response - self.app.config['EXTENDED_MEDIA_INFO'] = ['content_type', 'length'] - - # compare original and returned data - _id = r[self.id_field] - self.assertMediaFieldExtended(_id, self.encoded, self.clean) - - # GET the file at the resource endpoint - where = 'where={"%s": "%s"}' % (self.id_field, _id) - r, s = self.parse_response( - self.test_client.get('%s?%s' % (self.url, where))) - self.assertEqual(len(r['_items']), 1) - returned = r['_items'][0]['media'] - - # returned value is a base64 encoded string - self.assertEqual(returned['file'], self.encoded) - - # which decodes to the original clean - self.assertEqual(base64.decodestring(returned['file'].encode()), - self.clean) - - # also verify our extended fields - self.assertEqual(returned['content_type'], 'text/plain') - self.assertEqual(returned['length'], 16) - - def test_gridfs_media_storage_post_extended_excluded_file_in_result(self): - r, s = self._post() - self.assertEqual(STATUS_OK, r[STATUS]) - - # request extended format file response - self.app.config['EXTENDED_MEDIA_INFO'] = ['content_type', 'length'] - self.app.config['RETURN_MEDIA_AS_BASE64_STRING'] = False - # compare original and returned data - _id = r[self.id_field] - - # GET the file at the resource endpoint - where = 'where={"%s": "%s"}' % (self.id_field, _id) - r, s = self.parse_response( - self.test_client.get('%s?%s' % (self.url, where))) - self.assertEqual(len(r['_items']), 1) - returned = r['_items'][0]['media'] - - # returned value is None - self.assertEqual(returned['file'], None) - - # also verify our extended fields - self.assertEqual(returned['content_type'], 'text/plain') - self.assertEqual(returned['length'], 16) - - def test_gridfs_media_storage_put(self): - r, s = self._post() - _id = r[self.id_field] - etag = r[ETAG] - - # compare original and returned data - self.assertMediaField(_id, self.encoded, self.clean) - - with self.app.test_request_context(): - # retrieve media_id - media_id = self.assertMediaStored(_id) - - # PUT replaces the file with new one - clean = b'my new file contents' - encoded = base64.encodestring(clean).decode() - test_field, test_value = 'ref', "9234567890123456789054321" - data = {'media': (BytesIO(clean), 'test.txt'), test_field: test_value} - headers = [('Content-Type', 'multipart/form-data'), ('If-Match', etag)] - - r, s = self.parse_response( - self.test_client.put(('%s/%s' % (self.url, _id)), data=data, - headers=headers)) - self.assertEqual(STATUS_OK, r[STATUS]) - - with self.app.test_request_context(): - # media has been properly stored - self.assertMediaStored(_id) - - # compare original and returned data - r, s = self.assertMediaField(_id, encoded, clean) - - # and of course, the ordinary field has been updated too - self.assertEqual(r[test_field], test_value) - - with self.app.test_request_context(): - # previous media doesn't exist anymore (it's been deleted) - self.assertFalse(self.app.media.exists(media_id, self.resource)) - - def test_gridfs_media_storage_patch(self): - r, s = self._post() - _id = r[self.id_field] - etag = r[ETAG] - - # compare original and returned data - self.assertMediaField(_id, self.encoded, self.clean) - - with self.app.test_request_context(): - # retrieve media_id - media_id = self.assertMediaStored(_id) - - # PATCH replaces the file with new one - clean = b'my new file contents' - encoded = base64.encodestring(clean).decode() - test_field, test_value = 'ref', "9234567890123456789054321" - data = {'media': (BytesIO(clean), 'test.txt'), test_field: test_value} - headers = [('Content-Type', 'multipart/form-data'), ('If-Match', etag)] - - r, s = self.parse_response( - self.test_client.patch(('%s/%s' % (self.url, _id)), data=data, - headers=headers)) - self.assertEqual(STATUS_OK, r[STATUS]) - - # compare original and returned data - r, s = self.assertMediaField(_id, encoded, clean) - - # and of course, the ordinary field has been updated too - self.assertEqual(r[test_field], test_value) - - with self.app.test_request_context(): - # previous media doesn't exist anymore (it's been deleted) - self.assertFalse(self.app.media.exists(media_id, self.resource)) - - def test_gridfs_media_storage_patch_null(self): - # set 'media' field to 'nullable' - self.domain[self.known_resource]['schema']['media']['nullable'] = True - - response, status = self._post() - self.assert201(status) - - _id = response[self.id_field] - etag = response[ETAG] - - # test that nullable media field can be set to None - data = {'media': None} - headers = [('If-Match', etag)] - response, status = self.patch(('%s/%s' % (self.url, _id)), data=data, - headers=headers) - self.assert200(status) - - response, status = self.get(self.known_resource, item=_id) - self.assert200(status) - self.assertEqual(response['media'], None) - - def test_gridfs_media_storage_delete(self): - r, s = self._post() - _id = r[self.id_field] - etag = r[ETAG] - - with self.app.test_request_context(): - # retrieve media_id and compare original and returned data - self.assertMediaField(_id, self.encoded, self.clean) - - media_id = self.assertMediaStored(_id) - - # DELETE deletes both the document and the media file - headers = [('If-Match', etag)] - - r, s = self.parse_response( - self.test_client.delete(('%s/%s' % (self.url, _id)), - headers=headers)) - self.assert204(s) - - with self.app.test_request_context(): - # media doesn't exist anymore (it's been deleted) - self.assertFalse(self.app.media.exists(media_id, self.resource)) - - # GET returns 404 - r, s = self.parse_response(self.test_client.get('%s/%s' % (self.url, - _id))) - self.assert404(s) - - def test_gridfs_media_storage_delete_projection(self): - """ test that #284 is fixed: If you have a media field, and set - datasource projection to 0 for that field, the media will not be - deleted - """ - r, s = self._post() - _id = r[self.id_field] - - with self.app.test_request_context(): - # retrieve media_id and compare original and returned data - media_id = self.assertMediaStored(_id) - - self.app.config['DOMAIN']['contacts']['datasource']['projection'] = \ - {"media": 0} - - r, s = self.parse_response(self.test_client.get('%s/%s' % (self.url, - _id))) - etag = r[ETAG] - - # DELETE deletes both the document and the media file - headers = [('If-Match', etag)] - - r, s = self.parse_response( - self.test_client.delete(('%s/%s' % (self.url, _id)), - headers=headers)) - self.assert204(s) - - with self.app.test_request_context(): - # media doesn't exist anymore (it's been deleted) - self.assertFalse(self.app.media.exists(media_id, self.resource)) - - # GET returns 404 - r, s = self.parse_response(self.test_client.get('%s/%s' % (self.url, - _id))) - self.assert404(s) - - def test_gridfs_media_storage_return_url(self): - self.app._init_media_endpoint() - self.app.config['RETURN_MEDIA_AS_BASE64_STRING'] = False - self.app.config['RETURN_MEDIA_AS_URL'] = True - - r, s = self._post() - self.assertEqual(STATUS_OK, r[STATUS]) - _id = r[self.id_field] - - # GET the file at the resource endpoint - where = 'where={"%s": "%s"}' % (self.id_field, _id) - r, s = self.parse_response( - self.test_client.get('%s?%s' % (self.url, where))) - self.assertEqual(len(r['_items']), 1) - url = r['_items'][0]['media'] - - with self.app.test_request_context(): - media_id = self.assertMediaStored(_id) - - self.assertEqual('/media/%s' % media_id, url) - response = self.test_client.get(url) - self.assertEqual(self.clean, response.get_data()) - - def test_gridfs_media_storage_base_url(self): - self.app._init_media_endpoint() - self.app.config['RETURN_MEDIA_AS_BASE64_STRING'] = False - self.app.config['RETURN_MEDIA_AS_URL'] = True - self.app.config['MEDIA_BASE_URL'] = 'http://s3-us-west-2.amazonaws.com' - self.app.config['MEDIA_ENDPOINT'] = 'foo' - - r, s = self._post() - self.assertEqual(STATUS_OK, r[STATUS]) - _id = r[self.id_field] - - # GET the file at the resource endpoint - where = 'where={"%s": "%s"}' % (self.id_field, _id) - r, s = self.parse_response( - self.test_client.get('%s?%s' % (self.url, where))) - self.assertEqual(len(r['_items']), 1) - url = r['_items'][0]['media'] - - with self.app.test_request_context(): - media_id = self.assertMediaStored(_id) - self.assertEqual('%s/%s/%s' % (self.app.config['MEDIA_BASE_URL'], - self.app.config['MEDIA_ENDPOINT'], media_id), url) - - def assertMediaField(self, _id, encoded, clean): - # GET the file at the item endpoint - r, s = self.parse_response(self.test_client.get('%s/%s' % (self.url, - _id))) - returned = r['media'] - # returned value is a base64 encoded string - self.assertEqual(returned, encoded) - # which decodes to the original file clean - self.assertEqual(base64.decodestring(returned.encode()), clean) - return r, s - - def assertMediaFieldExtended(self, _id, encoded, clean): - # GET the file at the item endpoint - r, s = self.parse_response(self.test_client.get('%s/%s' % (self.url, - _id))) - returned = r['media']['file'] - # returned value is a base64 encoded string - self.assertEqual(returned, encoded) - # which decodes to the original file clean - self.assertEqual(base64.decodestring(returned.encode()), clean) - return r, s - - def assertMediaStored(self, _id): - _db = self.connection[MONGO_DBNAME] - - # retrieve media id - media_id = _db.contacts.find_one( - {self.id_field: ObjectId(_id)})['media'] - - # verify it's actually stored in the media storage system - self.assertTrue(self.app.media.exists(media_id, self.resource)) - return media_id - - def _post(self): - # send a file and a required, ordinary field with no issues - data = {'media': (BytesIO(self.clean), 'test.txt'), self.test_field: - self.test_value} - return self.parse_response(self.test_client.post( - self.url, data=data, headers=self.headers)) diff --git a/eve/tests/io/mongo.py b/eve/tests/io/mongo.py deleted file mode 100644 index c5e17cead..000000000 --- a/eve/tests/io/mongo.py +++ /dev/null @@ -1,366 +0,0 @@ -# -*- coding: utf-8 -*- -from datetime import datetime - -import simplejson as json -from bson import ObjectId -from bson.dbref import DBRef -from cerberus import SchemaError -from unittest import TestCase - -from eve.io.mongo import Validator, Mongo, MongoJSONEncoder -from eve.io.mongo.parser import parse, ParseError -from eve.tests import TestBase -from eve.tests.test_settings import MONGO_DBNAME - - -class TestPythonParser(TestCase): - - def test_Eq(self): - r = parse('a == "whatever"') - self.assertEqual(type(r), dict) - self.assertEqual(r, {'a': 'whatever'}) - - def test_Gt(self): - r = parse('a > 1') - self.assertEqual(type(r), dict) - self.assertEqual(r, {'a': {'$gt': 1}}) - - def test_GtE(self): - r = parse('a >= 1') - self.assertEqual(type(r), dict) - self.assertEqual(r, {'a': {'$gte': 1}}) - - def test_Lt(self): - r = parse('a < 1') - self.assertEqual(type(r), dict) - self.assertEqual(r, {'a': {'$lt': 1}}) - - def test_LtE(self): - r = parse('a <= 1') - self.assertEqual(type(r), dict) - self.assertEqual(r, {'a': {'$lte': 1}}) - - def test_NotEq(self): - r = parse('a != 1') - self.assertEqual(type(r), dict) - self.assertEqual(r, {'a': {'$ne': 1}}) - - def test_And_BoolOp(self): - r = parse('a == 1 and b == 2') - self.assertEqual(type(r), dict) - self.assertEqual(r, {'$and': [{'a': 1}, {'b': 2}]}) - - def test_Or_BoolOp(self): - r = parse('a == 1 or b == 2') - self.assertEqual(type(r), dict) - self.assertEqual(r, {'$or': [{'a': 1}, {'b': 2}]}) - - def test_nested_BoolOp(self): - r = parse('a == 1 or (b == 2 and c == 3)') - self.assertEqual(type(r), dict) - self.assertEqual(r, {'$or': [{'a': 1}, - {'$and': [{'b': 2}, {'c': 3}]}]}) - - def test_ObjectId_Call(self): - r = parse('_id == ObjectId("4f4644fbc88e20212c000000")') - self.assertEqual(type(r), dict) - self.assertEqual(r, {'_id': ObjectId("4f4644fbc88e20212c000000")}) - - def test_datetime_Call(self): - r = parse('born == datetime(2012, 11, 9)') - self.assertEqual(type(r), dict) - self.assertEqual(r, {'born': datetime(2012, 11, 9)}) - - def test_Attribute(self): - r = parse('Invoice.number == 1') - self.assertEqual(type(r), dict) - self.assertEqual(r, {'Invoice.number': 1}) - - def test_unparsed_statement(self): - self.assertRaises(ParseError, parse, 'print ("hello")') - - def test_bad_Expr(self): - self.assertRaises(ParseError, parse, 'a | 2') - - -class TestMongoValidator(TestCase): - def test_unique_fail(self): - """ relying on POST and PATCH tests since we don't have an active - app_context running here """ - pass - - def test_unique_success(self): - """ relying on POST and PATCH tests since we don't have an active - app_context running here """ - pass - - def test_objectid_fail(self): - schema = {'id': {'type': 'objectid'}} - doc = {'id': 'not_an_object_id'} - v = Validator(schema, None) - self.assertFalse(v.validate(doc)) - self.assertTrue('id' in v.errors) - self.assertTrue('ObjectId' in v.errors['id']) - - def test_objectid_success(self): - schema = {'id': {'type': 'objectid'}} - doc = {'id': ObjectId('50656e4538345b39dd0414f0')} - v = Validator(schema, None) - self.assertTrue(v.validate(doc)) - - def test_dbref_fail(self): - schema = {'id': {'type': 'dbref'}} - doc = {'id': 'not_an_object_id'} - v = Validator(schema, None) - self.assertFalse(v.validate(doc)) - self.assertTrue('id' in v.errors) - self.assertTrue('DBRef' in v.errors['id']) - - def test_dbref_success(self): - schema = {'id': {'type': 'dbref'}} - doc = {'id': DBRef("SomeCollection", - ObjectId("50656e4538345b39dd0414f0"))} - v = Validator(schema, None) - self.assertTrue(v.validate(doc)) - - def test_transparent_rules(self): - schema = {'a_field': {'type': 'string'}} - v = Validator(schema) - self.assertFalse(v.transparent_schema_rules) - - def test_reject_invalid_schema(self): - schema = {'a_field': {'foo': 'bar'}} - self.assertRaises(SchemaError, lambda: Validator(schema)) - - def test_enable_transparent_rules(self): - schema = {'a_field': {'type': 'string'}} - v = Validator(schema, transparent_schema_rules=True) - self.assertTrue(v.transparent_schema_rules) - - def test_transparent_rules_accept_invalid_schema(self): - schema = {'a_field': {'foo': 'bar'}} - Validator(schema, transparent_schema_rules=True) - - def test_geojson_not_compilant(self): - schema = {'location': {'type': 'point'}} - doc = {'location': [10.0, 123.0]} - v = Validator(schema) - self.assertFalse(v.validate(doc)) - self.assertTrue('location' in v.errors) - self.assertTrue('Point' in v.errors['location']) - - def test_geometry_not_compilant(self): - schema = {'location': {'type': 'point'}} - doc = {'location': {"type": "Point", "geometries": [10.0, 123.0]}} - v = Validator(schema) - self.assertFalse(v.validate(doc)) - self.assertTrue('location' in v.errors) - self.assertTrue('Point' in v.errors['location']) - - def test_geometrycollection_not_compilant(self): - schema = {'location': {'type': 'geometrycollection'}} - doc = {'location': {"type": "GeometryCollection", - "coordinates": [10.0, 123.0]}} - v = Validator(schema) - self.assertFalse(v.validate(doc)) - self.assertTrue('location' in v.errors) - self.assertTrue('GeometryCollection' in v.errors['location']) - - def test_point_success(self): - schema = {'location': {'type': 'point'}} - doc = {'location': {"type": "Point", "coordinates": [100.0, 0.0]}} - v = Validator(schema) - self.assertTrue(v.validate(doc)) - - def test_point_fail(self): - schema = {'location': {'type': 'point'}} - doc = {'location': {'type': "Point", 'coordinates': ["asdasd", 123.0]}} - v = Validator(schema) - self.assertFalse(v.validate(doc)) - self.assertTrue('location' in v.errors) - self.assertTrue('Point' in v.errors['location']) - - def test_point_integer_success(self): - schema = {'location': {'type': 'point'}} - doc = {'location': {'type': "Point", 'coordinates': [10, 123.0]}} - v = Validator(schema) - self.assertTrue(v.validate(doc)) - - def test_linestring_success(self): - schema = {'location': {'type': 'linestring'}} - doc = {'location': {"type": "LineString", - "coordinates": [[100.0, 0.0], [101.0, 1.0]] - }} - v = Validator(schema) - self.assertTrue(v.validate(doc)) - - def test_linestring_fail(self): - schema = {'location': {'type': 'linestring'}} - doc = {'location': {'type': "LineString", - 'coordinates': [[12.0, 123.0], [12, 'eve']]}} - v = Validator(schema) - self.assertFalse(v.validate(doc)) - self.assertTrue('location' in v.errors) - self.assertTrue('LineString' in v.errors['location']) - - def test_polygon_success(self): - schema = {'location': {'type': 'polygon'}} - doc = {'location': {"type": "Polygon", - "coordinates": [[[100.0, 0.0], [101.0, 0.0], - [101.0, 1.0], [100.0, 1.0], - [100.0, 0.0]] - ] - } - } - v = Validator(schema) - self.assertTrue(v.validate(doc)) - - def test_polygon_fail(self): - schema = {'location': {'type': 'polygon'}} - doc = {'location': {'type': "Polygon", - 'coordinates': [[[12.0, 23.0], [12.3, 12.5]], - ["eve"]]}} - v = Validator(schema) - self.assertFalse(v.validate(doc)) - self.assertTrue('location' in v.errors) - self.assertTrue('Polygon' in v.errors['location']) - - def test_multipoint_success(self): - schema = {'location': {'type': 'multipoint'}} - doc = {'location': {"type": "MultiPoint", - "coordinates": [[100.0, 0.0], [101.0, 1.0]] - } - } - v = Validator(schema) - self.assertTrue(v.validate(doc)) - - def test_multilinestring_success(self): - schema = {'location': {'type': 'multilinestring'}} - doc = {'location': {"type": "MultiLineString", - "coordinates": [[[100.0, 0.0], [101.0, 1.0]], - [[102.0, 2.0], [103.0, 3.0]] - ] - } - } - v = Validator(schema) - self.assertTrue(v.validate(doc)) - - def test_multipolygon_success(self): - schema = {'location': {'type': 'multipolygon'}} - doc = {'location': {"type": "MultiPolygon", - "coordinates": [[[[102.0, 2.0], [103.0, 2.0], - [103.0, 3.0], [102.0, 3.0], - [102.0, 2.0]]], - [[[100.0, 0.0], [101.0, 0.0], - [101.0, 1.0], [100.0, 1.0], - [100.0, 0.0]], - [[100.2, 0.2], [100.8, 0.2], - [100.8, 0.8], [100.2, 0.8], - [100.2, 0.2]]] - ] - } - } - v = Validator(schema) - self.assertTrue(v.validate(doc)) - - def test_geometrycollection_success(self): - schema = {'locations': {'type': 'geometrycollection'}} - doc = {'locations': {'type': "GeometryCollection", - "geometries": [{"type": "Point", - "coordinates": [100.0, 0.0]}, - {"type": "LineString", - "coordinates": [[101.0, 0.0], - [102.0, 1.0]] - } - ] - } - } - v = Validator(schema) - self.assertTrue(v.validate(doc)) - - def test_geometrycollection_fail(self): - schema = {'locations': {'type': 'geometrycollection'}} - doc = {'locations': {'type': "GeometryCollection", - "geometries": [{"type": "GeoJSON", - "badinput": "lolololololol"}] - } - } - v = Validator(schema) - self.assertFalse(v.validate(doc)) - self.assertTrue('locations' in v.errors) - self.assertTrue('GeometryCollection' in v.errors['locations']) - - def test_dependencies_with_defaults(self): - schema = { - 'test_field': {'dependencies': 'foo'}, - 'foo': {'type': 'string', 'default': 'foo'}, - 'bar': {'type': 'string', 'default': 'bar'} - } - doc = {'test_field': 'foobar'} - - # With `dependencies` as a str - v = Validator(schema) - self.assertTrue(v.validate(doc)) - - # With `dependencies` as a dict - schema['test_field'] = {'dependencies': {'foo': 'foo', 'bar': 'bar'}} - v = Validator(schema) - self.assertTrue(v.validate(doc)) - - # With `dependencies` as a list - schema['test_field'] = {'dependencies': ['foo', 'bar']} - v = Validator(schema) - self.assertTrue(v.validate(doc)) - - -class TestMongoDriver(TestBase): - - def test_combine_queries(self): - mongo = Mongo(None) - query_a = {'username': {'$exists': True}} - query_b = {'username': 'mike'} - combined = mongo.combine_queries(query_a, query_b) - self.assertEqual( - combined, - {'$and': [{'username': {'$exists': True}}, {'username': 'mike'}]} - ) - - def test_json_encoder_class(self): - mongo = Mongo(None) - self.assertTrue((mongo.json_encoder_class(), MongoJSONEncoder)) - self.assertTrue((mongo.json_encoder_class(), json.JSONEncoder)) - - def test_get_value_from_query(self): - mongo = Mongo(None) - simple_query = {'_id': 'abcdef012345678901234567'} - compound_query = {'$and': [ - {'username': {'$exists': False}}, - {'_id': 'abcdef012345678901234567'} - ]} - self.assertEqual(mongo.get_value_from_query(simple_query, '_id'), - 'abcdef012345678901234567') - self.assertEqual(mongo.get_value_from_query(compound_query, '_id'), - 'abcdef012345678901234567') - - def test_query_contains_field(self): - mongo = Mongo(None) - simple_query = {'_id': 'abcdef012345678901234567'} - compound_query = {'$and': [ - {'username': {'$exists': False}}, - {'_id': 'abcdef012345678901234567'} - ]} - self.assertTrue(mongo.query_contains_field(simple_query, '_id')) - self.assertFalse(mongo.query_contains_field(simple_query, - 'fake-field')) - self.assertTrue(mongo.query_contains_field(compound_query, '_id')) - self.assertFalse(mongo.query_contains_field(compound_query, - 'fake-field')) - - def test_delete_returns_status(self): - db = self.connection[MONGO_DBNAME] - count = db.contacts.count() - result = db.contacts.remove() - self.assertTrue(isinstance(result, dict)) - self.assertEqual(result.get('n'), count) - self.assertEqual(result.get('ok'), 1) - self.connection.close() diff --git a/eve/tests/io/multi_mongo.py b/eve/tests/io/multi_mongo.py deleted file mode 100644 index 9e31b69d0..000000000 --- a/eve/tests/io/multi_mongo.py +++ /dev/null @@ -1,230 +0,0 @@ -# -*- coding: utf-8 -*- -from datetime import datetime - -import json -from bson import ObjectId -from flask_pymongo import MongoClient - -import eve -from eve.auth import BasicAuth -from eve.tests import TestBase -from eve.tests.test_settings import MONGO1_PASSWORD, MONGO1_USERNAME, \ - MONGO1_DBNAME, MONGO_DBNAME - - -class TestMultiMongo(TestBase): - def setUp(self): - super(TestMultiMongo, self).setUp() - - self.setupDB2() - - schema = { - 'author': {'type': 'string'}, - 'title': {'type': 'string'}, - } - settings = { - 'schema': schema, - 'mongo_prefix': 'MONGO1' - } - - self.app.register_resource('works', settings) - - def tearDown(self): - super(TestMultiMongo, self).tearDown() - self.dropDB2() - - def setupDB2(self): - self.connection = MongoClient() - self.connection.drop_database(MONGO1_DBNAME) - self.connection[MONGO1_DBNAME].add_user(MONGO1_USERNAME, - MONGO1_PASSWORD) - self.bulk_insert2() - - def dropDB2(self): - self.connection = MongoClient() - self.connection.drop_database(MONGO1_DBNAME) - self.connection.close() - - def bulk_insert2(self): - _db = self.connection[MONGO1_DBNAME] - works = self.random_works(self.known_resource_count) - _db.works.insert(works) - self.work = _db.works.find_one() - self.connection.close() - - def random_works(self, num): - works = [] - for i in range(num): - dt = datetime.now() - work = { - 'author': self.random_string(20), - 'title': self.random_string(30), - eve.LAST_UPDATED: dt, - eve.DATE_CREATED: dt, - } - works.append(work) - return works - - -class TestMethodsAcrossMultiMongo(TestMultiMongo): - def test_get_multidb(self): - # test that a GET on 'works' reads from MONGO1 - id_field = self.domain['works']['id_field'] - r, s = self.get('works/%s' % self.work[id_field]) - self.assert200(s) - self.assertEqual(r['author'], self.work['author']) - - # while 'contacts' endpoint reads from MONGO - id_field = self.domain['contacts']['id_field'] - r, s = self.get(self.known_resource, item=self.item_id) - self.assert200(s) - self.assertEqual(r[id_field], self.item_id) - - def test_post_multidb(self): - # test that a POST on 'works' stores data to MONGO1 - work = self._save_work() - db = self.connection[MONGO1_DBNAME] - id_field = self.domain['works']['id_field'] - new = db.works.find_one({id_field: ObjectId(work[id_field])}) - self.assertTrue(new is not None) - self.connection.close() - - # while 'contacts' endpoint stores data to MONGO - contact = {'ref': '1234567890123456789054321'} - r, s = self.post(self.known_resource_url, data=contact) - self.assert201(s) - db = self.connection[MONGO_DBNAME] - id_field = self.domain['contacts']['id_field'] - new = db.contacts.find_one({id_field: ObjectId(r[id_field])}) - self.assertTrue(new is not None) - self.connection.close() - - def test_patch_multidb(self): - # test that a PATCH on 'works' udpates data on MONGO1 - work = self._save_work() - id_field = self.domain['works']['id_field'] - id, etag = work[id_field], work[eve.ETAG] - changes = {'author': 'mike'} - - headers = [('Content-Type', 'application/json'), ('If-Match', etag)] - r = self.test_client.patch('works/%s' % id, data=json.dumps(changes), - headers=headers) - self.assert200(r.status_code) - - db = self.connection[MONGO1_DBNAME] - updated = db.works.find_one({id_field: ObjectId(id)}) - self.assertEqual(updated['author'], 'mike') - self.connection.close() - - # while 'contacts' endpoint updates data on MONGO - field, value = "ref", "1234567890123456789012345" - changes = {field: value} - headers = [('Content-Type', 'application/json'), ('If-Match', - self.item_etag)] - id_field = self.domain['contacts']['id_field'] - r = self.test_client.patch(self.item_id_url, data=json.dumps(changes), - headers=headers) - self.assert200(r.status_code) - - db = self.connection[MONGO_DBNAME] - updated = db.contacts.find_one({id_field: ObjectId(self.item_id)}) - self.assertEqual(updated[field], value) - self.connection.close() - - def test_put_multidb(self): - # test that a PUT on 'works' udpates data on MONGO1 - work = self._save_work() - id_field = self.domain['works']['id_field'] - id, etag = work[id_field], work[eve.ETAG] - changes = {'author': 'mike', 'title': 'Eve for dummies'} - - headers = [('Content-Type', 'application/json'), ('If-Match', etag)] - r = self.test_client.put('works/%s' % id, data=json.dumps(changes), - headers=headers) - self.assert200(r.status_code) - - db = self.connection[MONGO1_DBNAME] - updated = db.works.find_one({id_field: ObjectId(id)}) - self.assertEqual(updated['author'], 'mike') - self.connection.close() - - # while 'contacts' endpoint updates data on MONGO - field, value = "ref", "1234567890123456789012345" - changes = {field: value} - headers = [('Content-Type', 'application/json'), ('If-Match', - self.item_etag)] - id_field = self.domain['contacts']['id_field'] - r = self.test_client.put(self.item_id_url, data=json.dumps(changes), - headers=headers) - self.assert200(r.status_code) - - db = self.connection[MONGO_DBNAME] - updated = db.contacts.find_one({id_field: ObjectId(self.item_id)}) - self.assertEqual(updated[field], value) - self.connection.close() - - def test_delete_multidb(self): - # test that DELETE on 'works' deletes data on MONGO1 - work = self._save_work() - id_field = self.domain['works']['id_field'] - id, etag = work[id_field], work[eve.ETAG] - r = self.test_client.delete('works/%s' % id, headers=[('If-Match', - etag)]) - self.assert204(r.status_code) - db = self.connection[MONGO1_DBNAME] - lost = db.works.find_one({id_field: ObjectId(id)}) - self.assertEqual(lost, None) - self.connection.close() - - # while 'contacts' still deletes on MONGO - r = self.test_client.delete(self.item_id_url, - headers=[('If-Match', self.item_etag)]) - self.assert204(r.status_code) - db = self.connection[MONGO_DBNAME] - id_field = self.domain['contacts']['id_field'] - lost = db.contacts.find_one({id_field: ObjectId(self.item_id)}) - self.assertEqual(lost, None) - self.connection.close() - - def _save_work(self): - work = {'author': 'john doe', 'title': 'Eve for Dummies'} - r, s = self.post('works', data=work) - self.assert201(s) - return r - - -class MyBasicAuth(BasicAuth): - def check_auth(self, username, password, allowed_roles, resource, method): - self.set_mongo_prefix('MONGO1') - return True - - -class TestMultiMongoAuth(TestMultiMongo): - def test_get_multidb(self): - self.domain['works']['mongo_prefix'] = 'MONGO' - self.domain['works']['public_item_methods'] = [] - - headers = [('Authorization', 'Basic YWRtaW46c2VjcmV0')] - - # this will 404 since there's no 'works' collection on MONGO, - id_field = self.domain['works']['id_field'] - r = self.test_client.get('works/%s' % self.work[id_field], - headers=headers) - self.assert404(r.status_code) - - # now set a custom auth class which sets mongo_prefix at MONGO1 - self.domain['works']['authentication'] = MyBasicAuth - - # this will 200 just fine as the custom auth class has precedence over - # endpoint configuration. - r = self.test_client.get('works/%s' % self.work[id_field], - headers=headers) - self.assert200(r.status_code) - # test that we are indeed reading from the correct database instance. - payl = json.loads(r.get_data().decode('utf-8')) - self.assertEqual(payl['author'], self.work['author']) - - # 'contacts' still reads from MONGO - r = self.test_client.get('%s/%s' % (self.known_resource_url, - self.item_id), headers=headers) - self.assert200(r.status_code) diff --git a/eve/tests/methods/common.py b/eve/tests/methods/common.py deleted file mode 100644 index 3e66d4ec5..000000000 --- a/eve/tests/methods/common.py +++ /dev/null @@ -1,732 +0,0 @@ -import time -from datetime import datetime - -import simplejson as json -from bson import ObjectId -from bson.dbref import DBRef - -from eve.methods.common import serialize, normalize_dotted_fields -from eve.tests import TestBase -from eve.tests.auth import ValidBasicAuth, ValidTokenAuth, ValidHMACAuth -from eve.tests.test_settings import MONGO_DBNAME -from eve.utils import config - - -class TestSerializer(TestBase): - def test_serialize_subdocument(self): - # tests fix for #244, serialization of sub-documents. - schema = {'personal': {'type': 'dict', - 'schema': {'best_friend': {'type': 'objectid'}, - 'born': {'type': 'datetime'}}}, - 'without_type': {}} - doc = {'personal': {'best_friend': '50656e4538345b39dd0414f0', - 'born': 'Tue, 06 Nov 2012 10:33:31 GMT'}, - 'without_type': 'foo'} - with self.app.app_context(): - serialized = serialize(doc, schema=schema) - self.assertTrue( - isinstance(serialized['personal']['best_friend'], ObjectId)) - self.assertTrue( - isinstance(serialized['personal']['born'], datetime)) - - def test_mongo_serializes(self): - schema = { - 'id': {'type': 'objectid'}, - 'date': {'type': 'datetime'}, - 'count': {'type': 'integer'}, - 'average': {'type': 'float'}, - 'dict_valueschema': { - 'valueschema': {'type': 'objectid'} - }, - 'refobj': {'type': 'dbref'} - } - with self.app.app_context(): - # Success - res = serialize( - { - 'id': '50656e4538345b39dd0414f0', - 'date': 'Tue, 06 Nov 2012 10:33:31 GMT', - 'count': 42, - 'average': 42.42, - 'dict_valueschema': { - 'foo1': '50656e4538345b39dd0414f0', - 'foo2': '50656e4538345b39dd0414f0', - }, - 'refobj': { - '$id': '50656e4538345b39dd0414f0', - '$col': 'SomeCollection' - } - }, - schema=schema - ) - self.assertTrue(isinstance(res['id'], ObjectId)) - self.assertTrue(isinstance(res['date'], datetime)) - self.assertTrue(isinstance(res['count'], int)) - self.assertTrue(isinstance(res['average'], float)) - - ks = res['dict_valueschema'] - self.assertTrue(isinstance(ks['foo1'], ObjectId)) - self.assertTrue(isinstance(ks['foo2'], ObjectId)) - self.assertTrue(isinstance(res['refobj'], DBRef)) - - def test_non_blocking_on_simple_field_serialization_exception(self): - schema = { - 'extract_time': {'type': 'datetime'}, - 'date': {'type': 'datetime'}, - 'total': {'type': 'integer'} - } - - with self.app.app_context(): - # Success - res = serialize( - { - 'extract_time': 'Tue, 06 Nov 2012 10:33:31 GMT', - 'date': 'Tue, 06 Nov 2012 10:33:31 GMT', - 'total': 'r123' - }, - schema=schema - ) - # this has been left untouched as it could not be serialized. - self.assertEqual(res['total'], 'r123') - # these have been both serialized. - self.assertTrue(isinstance(res['extract_time'], datetime)) - self.assertTrue(isinstance(res['date'], datetime)) - - def test_serialize_lists_of_lists(self): - # serialize should handle list of lists of basic types - schema = { - 'l_of_l': { - 'type': 'list', - 'schema': { - 'type': 'list', - 'schema': { - 'type': 'objectid' - } - } - } - } - doc = { - 'l_of_l': [ - ['50656e4538345b39dd0414f0', '50656e4538345b39dd0414f0'], - ['50656e4538345b39dd0414f0', '50656e4538345b39dd0414f0'] - ] - } - - with self.app.app_context(): - serialized = serialize(doc, schema=schema) - for sublist in serialized['l_of_l']: - for item in sublist: - self.assertTrue(isinstance(item, ObjectId)) - - # serialize should handle list of lists of dicts - schema = { - 'l_of_l': { - 'type': 'list', - 'schema': { - 'type': 'list', - 'schema': { - 'type': 'dict', - 'schema': { - '_id': { - 'type': 'objectid' - } - } - } - } - } - } - doc = { - 'l_of_l': [ - [ - {'_id': '50656e4538345b39dd0414f0'}, - {'_id': '50656e4538345b39dd0414f0'} - ], - [ - {'_id': '50656e4538345b39dd0414f0'}, - {'_id': '50656e4538345b39dd0414f0'} - ], - ] - } - with self.app.app_context(): - serialized = serialize(doc, schema=schema) - for sublist in serialized['l_of_l']: - for item in sublist: - self.assertTrue(isinstance(item['_id'], ObjectId)) - - def test_dbref_serialize_lists_of_lists(self): - # serialize should handle list of lists of basic types - schema = { - 'l_of_l': { - 'type': 'list', - 'schema': { - 'type': 'list', - 'schema': { - 'type': 'dbref' - } - } - } - } - doc = { - 'l_of_l': [ - [{'$col': 'SomeCollection', '$id': '50656e4538345b39dd0414f0'}, - {'$col': 'SomeCollection', '$id': '50656e4538345b39dd0414f0'} - ], - [{'$col': 'SomeCollection', '$id': '50656e4538345b39dd0414f0'}, - {'$col': 'SomeCollection', '$id': '50656e4538345b39dd0414f0'} - ] - ] - } - - with self.app.app_context(): - serialized = serialize(doc, schema=schema) - for sublist in serialized['l_of_l']: - for item in sublist: - self.assertTrue(isinstance(item, DBRef)) - - # serialize should handle list of lists of dicts - schema = { - 'l_of_l': { - 'type': 'list', - 'schema': { - 'type': 'list', - 'schema': { - 'type': 'dict', - 'schema': { - '_id': { - 'type': 'dbref' - } - } - } - } - } - } - doc = { - 'l_of_l': [ - [ - {'_id': {'$col': 'SomeCollection', - '$id': '50656e4538345b39dd0414f0'} - }, - {'_id': {'$col': 'SomeCollection', - '$id': '50656e4538345b39dd0414f0'} - } - ], - [ - {'_id': {'$col': 'SomeCollection', - '$id': '50656e4538345b39dd0414f0'} - }, - {'_id': {'$col': 'SomeCollection', - '$id': '50656e4538345b39dd0414f0'} - } - ], - ] - } - with self.app.app_context(): - serialized = serialize(doc, schema=schema) - for sublist in serialized['l_of_l']: - for item in sublist: - self.assertTrue(isinstance(item['_id'], DBRef)) - - def test_serialize_null_dictionary(self): - # Serialization should continue after encountering a null value dict - # field. Field may be nullable, or error will be caught in validation. - schema = { - 'nullable_dict': { - 'type': 'dict', - 'nullable': True, - 'schema': { - 'simple_field': { - 'type': 'number' - } - } - } - } - doc = { - 'nullable_dict': None - } - with self.app.app_context(): - try: - serialize(doc, schema=schema) - except Exception: - self.assertTrue(False, "Serializing null dictionaries should " - "not raise an exception.") - - def test_serialize_null_list(self): - schema = { - 'nullable_list': { - 'type': 'list', - 'nullable': True, - 'schema': { - 'type': 'objectid' - } - } - } - doc = { - 'nullable_list': None - } - with self.app.app_context(): - try: - serialize(doc, schema=schema) - except Exception: - self.fail('Serializing null lists' - ' should not raise an exception') - - schema = { - 'nullable_list': { - 'type': 'list', - 'nullable': True, - 'schema': { - 'type': 'dbref' - } - } - } - doc = { - 'nullable_list': None - } - with self.app.app_context(): - try: - serialize(doc, schema=schema) - except Exception: - self.fail('Serializing null lists' - ' should not raise an exception') - - def test_serialize_number(self): - schema = { - 'anumber': { - 'type': 'number', - } - } - for expected_type, value in [(int, '35'), (float, '3.5')]: - doc = { - 'anumber': value - } - with self.app.app_context(): - serialized = serialize(doc, schema=schema) - self.assertTrue( - isinstance(serialized['anumber'], expected_type) - ) - - def test_serialize_inside_x_of_rules(self): - for x_of in ['allof', 'anyof', 'oneof', 'noneof']: - schema = { - 'x_of-field': { - x_of: [ - {'type': 'objectid'}, - {'required': True} - ] - } - } - doc = {'x_of-field': '50656e4538345b39dd0414f0'} - with self.app.app_context(): - serialized = serialize(doc, schema=schema) - self.assertTrue(isinstance(serialized['x_of-field'], ObjectId)) - - def test_serialize_inside_nested_x_of_rules(self): - schema = { - 'nested-x_of-field': { - 'oneof': [ - { - 'anyof': [ - {'type': 'objectid'}, - {'type': 'datetime'} - ], - 'required': True - }, - { - 'allof': [ - {'type': 'boolean'}, - {'required': True} - ] - } - ] - } - } - doc = {'nested-x_of-field': '50656e4538345b39dd0414f0'} - with self.app.app_context(): - serialized = serialize(doc, schema=schema) - self.assertTrue( - isinstance(serialized['nested-x_of-field'], ObjectId)) - - def test_serialize_inside_x_of_typesavers(self): - for x_of in ['allof', 'anyof', 'oneof', 'noneof']: - schema = { - 'x_of-field': { - '{0}_type'.format(x_of): ['objectid', 'float', 'boolean'] - } - } - doc = {'x_of-field': '50656e4538345b39dd0414f0'} - with self.app.app_context(): - serialized = serialize(doc, schema=schema) - self.assertTrue(isinstance(serialized['x_of-field'], ObjectId)) - - def test_serialize_inside_list_of_x_of_rules(self): - for x_of in ['allof', 'anyof', 'oneof', 'noneof']: - schema = { - 'list-field': { - 'type': 'list', - 'schema': { - x_of: [ - { - 'type': 'objectid', - 'required': True} - ] - } - } - } - doc = {'list-field': ['50656e4538345b39dd0414f0']} - with self.app.app_context(): - serialized = serialize(doc, schema=schema) - serialized_oid = serialized['list-field'][0] - self.assertTrue(isinstance(serialized_oid, ObjectId)) - - def test_serialize_inside_list_of_schema_of_x_of_rules(self): - for x_of in ['allof', 'anyof', 'oneof', 'noneof']: - schema = { - 'list-field': { - 'type': 'list', - 'schema': { - x_of: [ - { - 'type': 'dict', - 'schema': { - 'x_of-field': { - 'type': 'objectid', - 'required': True - } - } - } - ] - } - } - } - doc = {'list-field': [{'x_of-field': '50656e4538345b39dd0414f0'}]} - with self.app.app_context(): - serialized = serialize(doc, schema=schema) - serialized_oid = serialized['list-field'][0]['x_of-field'] - self.assertTrue(isinstance(serialized_oid, ObjectId)) - - def test_serialize_inside_list_of_x_of_typesavers(self): - for x_of in ['allof', 'anyof', 'oneof', 'noneof']: - schema = { - 'list-field': { - 'type': 'list', - 'schema': { - '{0}_type'.format(x_of): [ - 'objectid', 'float', 'boolean' - ] - } - } - } - doc = {'list-field': ['50656e4538345b39dd0414f0']} - with self.app.app_context(): - serialized = serialize(doc, schema=schema) - serialized_oid = serialized['list-field'][0] - self.assertTrue(isinstance(serialized_oid, ObjectId)) - - -class TestNormalizeDottedFields(TestBase): - def test_normalize_dotted_fields(self): - def compare_recursive(a, b): - for key, value in a.items(): - if key not in b: - return False - if isinstance(value, dict): - compare_recursive(value, b[key]) - return True - - document = { - 'a.b': 1, - 'c.d': { - 'e.f': { - 'g': 1, - 'h': 2, - }, - 'e.f.i': {'j.k': 3, - }, - }, - 'l': [ - { - 'm.n': 4, - }, - ], - } - expected_result = { - 'a': { - 'b': 1, - }, - 'c': { - 'd': { - 'e': { - 'f': { - 'g': 1, - 'h': 2, - 'i': { - 'j': { - 'k': 3, - }, - }, - }, - }, - }, - }, - 'l': [ - { - 'm': { - 'n': 4, - }, - }, - ], - } - normalize_dotted_fields(document) - self.assertTrue(compare_recursive(document, expected_result)) - - -class TestOpLogBase(TestBase): - def setUp(self): - super(TestOpLogBase, self).setUp() - self.test_field, self.test_value = 'ref', "1234567890123456789054321" - self.data = {self.test_field: self.test_value} - self.test_client = self.app.test_client() - self.headers = [(('Content-Type', 'application/json'))] - - def oplog_reset(self): - self.app._init_oplog() - self.app.register_resource('oplog', self.domain['oplog']) - - settings = self.app.config['DOMAIN']['oplog'] - datasource = settings['datasource'] - schema = settings['schema'] - datasource['projection'] = {} - self.app._set_resource_projection(datasource, schema, settings) - - def oplog_get(self, url='/oplog'): - r = self.test_client.get(url) - return self.parse_response(r) - - def assertOpLogEntry(self, entry, op, user=None): - self.assertTrue('r' in entry) - self.assertTrue('i' in entry) - self.assertTrue(config.LAST_UPDATED in entry) - self.assertTrue(config.DATE_CREATED in entry) - self.assertTrue('o' in entry) - self.assertEqual(entry['o'], op) - self.assertTrue('127.0.0.1' in entry['ip']) - if op in self.app.config['OPLOG_CHANGE_METHODS']: - self.assertTrue('c' in entry) - self.assertTrue('u' in entry) - if user: - self.assertTrue(user in entry['u']) - else: - self.assertTrue('n/a' in entry['u']) - - -class TestOpLogEndpointDisabled(TestOpLogBase): - def setUp(self): - super(TestOpLogEndpointDisabled, self).setUp() - - self.app.config['OPLOG'] = True - from eve.default_settings import OPLOG_CHANGE_METHODS - self.app.config['OPLOG_CHANGE_METHODS'] = OPLOG_CHANGE_METHODS - self.oplog_reset() - - def test_post_oplog(self): - r = self.test_client.post(self.known_resource_url, - data=json.dumps(self.data), - headers=self.headers, - environ_base={'REMOTE_ADDR': '127.0.0.1'}) - - # oplog endpoint is not available. - r, status = self.oplog_get() - self.assert404(status) - - # however the oplog collection has been updated. - db = self.connection[MONGO_DBNAME] - cursor = db.oplog.find() - self.assertEqual(cursor.count(), 1) - self.assertOpLogEntry(cursor[0], 'POST') - - -class TestOpLogEndpointEnabled(TestOpLogBase): - def setUp(self): - super(TestOpLogEndpointEnabled, self).setUp() - - self.app.config['OPLOG'] = True - self.app.config['OPLOG_ENDPOINT'] = 'oplog' - self.oplog_reset() - - def test_oplog_hook(self): - def oplog_callback(resource, entries): - for entry in entries: - entry['extra'] = {'customfield': 'customvalue'} - - self.app.on_oplog_push += oplog_callback - - r = self.test_client.post(self.known_resource_url, - data=json.dumps(self.data), - headers=self.headers, - environ_base={'REMOTE_ADDR': '127.0.0.1'}) - - # oplog enpoint does not expose the 'extra' field - r, status = self.oplog_get() - self.assert200(status) - self.assertEqual(len(r['_items']), 1) - oplog_entry = r['_items'][0] - self.assertOpLogEntry(oplog_entry, 'POST') - self.assertTrue('extra' not in oplog_entry) - - # however the oplog collection has the field. - db = self.connection[MONGO_DBNAME] - cursor = db.oplog.find() - self.assertEqual(cursor.count(), 1) - oplog_entry = cursor[0] - self.assertTrue('extra' in oplog_entry) - self.assertTrue('customvalue' in oplog_entry['extra']['customfield']) - - # enable 'extra' field for the endpoint - self.app.config['OPLOG_RETURN_EXTRA_FIELD'] = True - self.oplog_reset() - - # now the oplog endpoint includes the 'extra' field - r, status = self.oplog_get() - self.assert200(status) - self.assertEqual(len(r['_items']), 1) - oplog_entry = r['_items'][0] - self.assertOpLogEntry(oplog_entry, 'POST') - self.assertTrue('extra' in oplog_entry) - self.assertTrue('customvalue' in oplog_entry['extra']['customfield']) - - def test_post_oplog(self): - r = self.test_client.post(self.known_resource_url, - data=json.dumps(self.data), - headers=self.headers, - environ_base={'REMOTE_ADDR': '127.0.0.1'}) - r, status = self.oplog_get() - self.assert200(status) - self.assertEqual(len(r['_items']), 1) - oplog_entry = r['_items'][0] - self.assertOpLogEntry(oplog_entry, 'POST') - self.assertTrue('extra' not in oplog_entry) - - def test_patch_oplog(self): - self.headers.append(('If-Match', self.item_etag)) - r = self.test_client.patch(self.item_id_url, - data=json.dumps(self.data), - headers=self.headers, - environ_base={'REMOTE_ADDR': '127.0.0.1'}) - r, status = self.oplog_get() - self.assert200(status) - self.assertEqual(len(r['_items']), 1) - oplog_entry = r['_items'][0] - self.assertOpLogEntry(oplog_entry, 'PATCH') - - def test_put_oplog(self): - self.headers.append(('If-Match', self.item_etag)) - r = self.test_client.put(self.item_id_url, - data=json.dumps(self.data), - headers=self.headers, - environ_base={'REMOTE_ADDR': '127.0.0.1'}) - r, status = self.oplog_get() - self.assert200(status) - self.assertEqual(len(r['_items']), 1) - oplog_entry = r['_items'][0] - self.assertOpLogEntry(oplog_entry, 'PUT') - - def test_put_oplog_does_not_alter_document(self): - """ Make sure we don't alter document ETag when performing an - oplog_push. See #590. """ - self.headers.append(('If-Match', self.item_etag)) - r = self.test_client.put(self.item_id_url, - data=json.dumps(self.data), - headers=self.headers, - environ_base={'REMOTE_ADDR': '127.0.0.1'}) - - etag1 = json.loads(r.get_data())['_etag'] - etag2 = json.loads( - self.test_client.get(self.item_id_url).get_data())['_etag'] - self.assertEqual(etag1, etag2) - - def test_delete_oplog(self): - self.headers.append(('If-Match', self.item_etag)) - r = self.test_client.delete(self.item_id_url, - headers=self.headers, - environ_base={'REMOTE_ADDR': '127.0.0.1'}) - r, status = self.oplog_get() - self.assert200(status) - self.assertEqual(len(r['_items']), 1) - oplog_entry = r['_items'][0] - self.assertOpLogEntry(oplog_entry, 'DELETE') - - def test_soft_delete_oplog(self): - r, s = self.parse_response(self.test_client.get(self.item_id_url)) - doc_date = r[config.LAST_UPDATED] - time.sleep(1) - - self.domain[self.known_resource]['soft_delete'] = True - - self.headers.append(('If-Match', self.item_etag)) - r = self.test_client.delete(self.item_id_url, - headers=self.headers, - environ_base={'REMOTE_ADDR': '127.0.0.1'}) - r, status = self.oplog_get() - self.assert200(status) - self.assertEqual(len(r['_items']), 1) - oplog_entry = r['_items'][0] - self.assertOpLogEntry(oplog_entry, 'DELETE') - self.assertTrue(doc_date != oplog_entry[config.LAST_UPDATED]) - - def test_post_oplog_with_basic_auth(self): - self.domain['contacts']['authentication'] = ValidBasicAuth - self.headers.append(('Authorization', 'Basic YWRtaW46c2VjcmV0')) - r = self.test_client.post(self.known_resource_url, - data=json.dumps(self.data), - headers=self.headers, - environ_base={'REMOTE_ADDR': '127.0.0.1'}) - r, status = self.oplog_get() - self.assert200(status) - self.assertEqual(len(r['_items']), 1) - oplog_entry = r['_items'][0] - self.assertOpLogEntry(oplog_entry, 'POST', 'admin') - - def test_post_oplog_with_token_auth(self): - self.domain['contacts']['authentication'] = ValidTokenAuth - self.headers.append(('Authorization', 'Basic dGVzdF90b2tlbjo=')) - r = self.test_client.post(self.known_resource_url, - data=json.dumps(self.data), - headers=self.headers, - environ_base={'REMOTE_ADDR': '127.0.0.1'}) - r, status = self.oplog_get() - self.assert200(status) - self.assertEqual(len(r['_items']), 1) - oplog_entry = r['_items'][0] - self.assertOpLogEntry(oplog_entry, 'POST', 'test_token') - - def test_post_oplog_with_hmac_auth(self): - self.domain['contacts']['authentication'] = ValidHMACAuth - self.headers.append(('Authorization', 'admin:secret')) - r = self.test_client.post(self.known_resource_url, - data=json.dumps(self.data), - headers=self.headers, - environ_base={'REMOTE_ADDR': '127.0.0.1'}) - r, status = self.oplog_get() - self.assert200(status) - self.assertEqual(len(r['_items']), 1) - oplog_entry = r['_items'][0] - self.assertOpLogEntry(oplog_entry, 'POST', 'admin') - - def patch(self, url, data, headers=[], content_type='application/json'): - headers.append(('Content-Type', content_type)) - headers.append(('If-Match', self.item_etag)) - r = self.test_client.patch(url, data=json.dumps(data), headers=headers) - return self.parse_response(r) - - def put(self, url, data, headers=[], content_type='application/json'): - headers.append(('Content-Type', content_type)) - headers.append(('If-Match', self.item_etag)) - r = self.test_client.put(url, data=json.dumps(data), headers=headers) - return self.parse_response(r) - - -class TestTickets(TestBase): - def test_ticket_681(self): - # See https://github.com/nicolaiarocci/eve/issues/681 - with self.app.test_request_context('not_an_existing_endpoint'): - self.app.data.driver.db['again'] diff --git a/eve/tests/methods/get.py b/eve/tests/methods/get.py deleted file mode 100644 index 566ce43b0..000000000 --- a/eve/tests/methods/get.py +++ /dev/null @@ -1,1802 +0,0 @@ -import base64 -import time -from io import BytesIO -import simplejson as json -from datetime import datetime, timedelta -from bson import ObjectId -from bson.son import SON -from werkzeug.datastructures import ImmutableMultiDict -from eve.tests import TestBase -from eve.tests.utils import DummyEvent -from eve.tests.test_settings import MONGO_DBNAME -from eve.utils import str_to_date, date_to_rfc1123 -from werkzeug import MultiDict -from eve.methods.get import get_internal, getitem_internal - - -class TestGet(TestBase): - - def test_get_empty_resource(self): - response, status = self.get(self.empty_resource) - self.assert200(status) - - resource = response['_items'] - self.assertEqual(len(resource), 0) - - links = response['_links'] - self.assertEqual(len(links), 2) - self.assertResourceLink(links, self.empty_resource) - self.assertHomeLink(links) - - def test_get_max_results(self): - maxr = 10 - response, status = self.get(self.known_resource, - '?max_results=%d' % maxr) - self.assert200(status) - - resource = response['_items'] - self.assertEqual(len(resource), maxr) - - maxr = self.app.config['PAGINATION_LIMIT'] + 1 - response, status = self.get(self.known_resource, - '?max_results=%d' % maxr) - self.assert200(status) - resource = response['_items'] - self.assertEqual(len(resource), self.app.config['PAGINATION_LIMIT']) - - def test_get_custom_max_results(self): - self.app.config['QUERY_MAX_RESULTS'] = 'size' - maxr = 10 - response, status = self.get(self.known_resource, '?size=%d' % maxr) - self.assert200(status) - resource = response['_items'] - self.assertEqual(len(resource), maxr) - - def test_get_custom_params(self): - page = 2 - custom_params = MultiDict([('my_param', 'value1'), - ('my_param', 'value2')]) - custom_query = '&'.join('%s=%s' % (param, value) for param, values - in custom_params.lists() for value in values) - response, status = self.get(self.known_resource, - '?%s&page=%d' % (custom_query, page)) - self.assert200(status) - - links = response['_links'] - self.assertCustomParams(links['prev'], custom_params) - self.assertCustomParams(links['next'], custom_params) - self.assertCustomParams(links['self'], custom_params) - self.assertCustomParams(links['last'], custom_params) - - def test_get_page(self): - response, status = self.get(self.known_resource) - self.assert200(status) - self.assertPage(response, status) - - def test_get_internal_page(self): - with self.app.test_request_context(self.known_resource_url): - response, _, _, status, _ = get_internal(self.known_resource) - self.assertPage(response, status) - - def assertPage(self, response, status): - links = response['_links'] - self.assertNextLink(links, 2) - self.assertLastLink(links, 5) - self.assertPagination(response, 1, 101, 25) - - page = 1 - response, status = self.get(self.known_resource, '?page=%d' % page) - self.assert200(status) - - links = response['_links'] - self.assertNextLink(links, 2) - self.assertLastLink(links, 5) - self.assertPagination(response, 1, 101, 25) - - page = 2 - response, status = self.get(self.known_resource, '?page=%d' % page) - self.assert200(status) - - links = response['_links'] - self.assertNextLink(links, 3) - self.assertPrevLink(links, 1) - self.assertLastLink(links, 5) - self.assertPagination(response, 2, 101, 25) - - page = 5 - response, status = self.get(self.known_resource, '?page=%d' % page) - self.assert200(status) - - links = response['_links'] - self.assertPrevLink(links, 4) - self.assertLastLink(links, None) - self.assertPagination(response, 5, 101, 25) - - def test_get_custom_page(self): - self.app.config['QUERY_PAGE'] = 'custom' - - page = 2 - response, status = self.get(self.known_resource, '?custom=%d' % page) - self.assert200(status) - - links = response['_links'] - self.assertNextLink(links, 3) - self.assertPrevLink(links, 1) - self.assertLastLink(links, 5) - self.assertPagination(response, 2, 101, 25) - - def test_get_pagination_no_documents(self): - """ test that pagination meta is present even when no records are being - returned. #415. - """ - response, status = self.get(self.known_resource, - '?where={"ref": "not_really"}') - self.assert200(status) - self.assertPagination(response, 1, 0, 25) - - def test_get_paging_disabled_no_args(self): - self.app.config['DOMAIN'][self.known_resource]['pagination'] = False - response, status = self.get(self.known_resource) - self.assert200(status) - resource = response['_items'] - self.assertEqual(len(resource), self.known_resource_count) - self.assertTrue(self.app.config['META'] not in response) - links = response['_links'] - self.assertTrue('next' not in links) - self.assertTrue('prev' not in links) - - def test_get_total_count_header(self): - url = self.domain[self.known_resource]['url'] - r = self.test_client.head(url) - response, status = self.parse_response(r) - self.assert200(status) - self.assertEqual(response, None) - - total_count = r.headers[self.app.config['HEADER_TOTAL_COUNT']] - self.assertEqual(int(total_count), self.known_resource_count) - - def test_get_where_mongo_syntax(self): - where = '{"ref": "%s"}' % self.item_name - response, status = self.get(self.known_resource, '?where=%s' % where) - self.assert200(status) - - resource = response['_items'] - self.assertEqual(len(resource), 1) - - def test_get_where_mongo_combined_date(self): - where = '{"$and": [{"ref": "%s"}, {"_created": \ - {"$gte": "Tue, 01 Oct 2013 00:59:22 GMT"}}]}' % self.item_name - response, status = self.get(self.known_resource, - '?where=%s' % where) - self.assert200(status) - - resource = response['_items'] - self.assertEqual(len(resource), 1) - - def test_get_custom_where(self): - self.app.config['QUERY_WHERE'] = 'whereas' - where = '{"ref": "%s"}' % self.item_name - response, status = self.get(self.known_resource, '?whereas=%s' % where) - self.assert200(status) - - resource = response['_items'] - self.assertEqual(len(resource), 1) - - def test_get_mongo_query_blacklist(self): - where = '{"$where": "this.ref == ''%s''"}' % self.item_name - _, status = self.get(self.known_resource, '?where=%s' % where) - self.assert400(status) - - where = '{"ref": {"$regex": "%s"}}' % self.item_name - _, status = self.get(self.known_resource, '?where=%s' % where) - self.assert400(status) - - def test_get_where_mongo_objectid_as_string(self): - where = '{"tid": "%s"}' % self.item_tid - response, status = self.get(self.known_resource, '?where=%s' % where) - self.assert200(status) - resource = response['_items'] - self.assertEqual(len(resource), 1) - - self.app.config['DOMAIN']['contacts']['query_objectid_as_string'] = \ - True - response, status = self.get(self.known_resource, '?where=%s' % where) - self.assert200(status) - resource = response['_items'] - self.assertEqual(len(resource), 0) - - def test_get_where_python_syntax(self): - where = 'ref == %s' % self.item_name - response, status = self.get(self.known_resource, '?where=%s' % where) - self.assert200(status) - - resource = response['_items'] - self.assertEqual(len(resource), 1) - - def test_get_where_python_syntax1(self): - where = 'ref == %s and _created>="Tue, 01 Oct 2013 00:59:22 GMT"' \ - % self.item_name - response, status = self.get(self.known_resource, '?where=%s' % where) - self.assert200(status) - - resource = response['_items'] - self.assertEqual(len(resource), 1) - - def test_get_query_in_links(self): - """ Make sure that query strings appear in all HATEOAS links (#464). - """ - # find a role with enough results - for role in ('agent', 'client', 'vendor'): - where = 'role == %s' % role - response, _ = self.get(self.known_resource, '?where=%s' % where) - if response['_meta']['total'] \ - >= self.app.config['PAGINATION_DEFAULT'] + 1: - break - links = response['_links'] - total = response['_meta']['total'] - max_results = response['_meta']['max_results'] - last_page = total / max_results + (1 if total % max_results else 0) - self.assertTrue('?where=%s' % where in links['self']['href']) - self.assertTrue('?where=%s' % where in links['next']['href']) - self.assertTrue('?where=%s' % where in links['last']['href']) - self.assertNextLink(links, 2) - self.assertLastLink(links, last_page) - - page = 2 - response, _ = self.get(self.known_resource, - '?where=%s&page=%d' % (where, page)) - links = response['_links'] - self.assertTrue('?where=%s' % where in links['prev']['href']) - self.assertPrevLink(links, 1) - - def test_get_projection_consistent_etag(self): - """ Test that #369 is fixed and projection queries return consistent - etags (as they are now stored along with the document). - """ - etag_field = self.app.config['ETAG'] - data = {"inv_number": self.random_string(10)} - - # post a new item so etag storage kicks in - r, status = self.post(self.empty_resource_url, data=data) - etag = r[etag_field] - - # hit the resource endpoint with a projection query - projection = '{"prog": 1}' - r, status = self.get(self.empty_resource, - '?projection=%s' % projection) - # compare original etag with retrieved one - self.assertEqual(etag, r['_items'][0][etag_field]) - - def test_get_projection(self): - projection = '{"prog": 1}' - response, status = self.get(self.known_resource, '?projection=%s' % - projection) - self.assert200(status) - - resource = response['_items'] - - for r in resource: - self.assertFalse('location' in r) - self.assertFalse('role' in r) - self.assertTrue('prog' in r) - self.assertTrue(self.domain[self.known_resource]['id_field'] in r) - self.assertTrue(self.app.config['ETAG'] in r) - self.assertTrue(self.app.config['LAST_UPDATED'] in r) - self.assertTrue(self.app.config['DATE_CREATED'] in r) - self.assertTrue(r[self.app.config['LAST_UPDATED']] != self.epoch) - self.assertTrue(r[self.app.config['DATE_CREATED']] != self.epoch) - - projection = '{"prog": 0}' - response, status = self.get(self.known_resource, '?projection=%s' % - projection) - self.assert200(status) - - resource = response['_items'] - - for r in resource: - self.assertFalse('prog' in r) - self.assertTrue('location' in r) - self.assertTrue('role' in r) - self.assertTrue(self.domain[self.known_resource]['id_field'] in r) - self.assertTrue(self.app.config['ETAG'] in r) - self.assertTrue(self.app.config['LAST_UPDATED'] in r) - self.assertTrue(self.app.config['DATE_CREATED'] in r) - self.assertTrue(r[self.app.config['LAST_UPDATED']] != self.epoch) - self.assertTrue(r[self.app.config['DATE_CREATED']] != self.epoch) - - def test_get_static_projection(self): - """ Test that static projections are honoured """ - response, status = self.get(self.different_resource) - self.assert200(status) - - resource = response['_items'] - - # 'users' has a static inclusive projection with 'username' and 'ref' - # fields, so other document fields should be excluded. - for r in resource: - self.assertFalse('location' in r) - self.assertFalse('role' in r) - self.assertFalse('prog' in r) - self.assertTrue('username' in r) - self.assertTrue('ref' in r) - self.assertTrue(self.domain[self.known_resource]['id_field'] in r) - self.assertTrue(self.app.config['ETAG'] in r) - self.assertTrue(self.app.config['LAST_UPDATED'] in r) - self.assertTrue(self.app.config['DATE_CREATED'] in r) - self.assertTrue(r[self.app.config['LAST_UPDATED']] != self.epoch) - self.assertTrue(r[self.app.config['DATE_CREATED']] != self.epoch) - - def test_get_custom_projection(self): - self.app.config['QUERY_PROJECTION'] = 'view' - projection = '{"prog": 1}' - response, status = self.get(self.known_resource, '?view=%s' % - projection) - self.assert200(status) - - resource = response['_items'] - - for r in resource: - self.assertFalse('location' in r) - self.assertFalse('role' in r) - self.assertTrue('prog' in r) - - def test_get_projection_subdocument(self): - projection = '{"location.address": 1}' - response, status = self.get(self.known_resource, '?projection=%s' % - projection) - self.assert200(status) - - resource = response['_items'] - - for r in resource: - self.assertTrue('location' in r) - self.assertTrue('address' in r['location']) - self.assertFalse('city' in r['location']) - self.assertFalse('role' in r) - self.assertFalse('prog' in r) - self.assertTrue(self.domain[self.known_resource]['id_field'] in r) - self.assertTrue(self.app.config['ETAG'] in r) - self.assertTrue(self.app.config['LAST_UPDATED'] in r) - self.assertTrue(self.app.config['DATE_CREATED'] in r) - self.assertTrue(r[self.app.config['LAST_UPDATED']] != self.epoch) - self.assertTrue(r[self.app.config['DATE_CREATED']] != self.epoch) - - def test_get_projection_noschema(self): - self.app.config['DOMAIN'][self.known_resource]['schema'] = {} - response, status = self.get(self.known_resource) - self.assert200(status) - - resource = response['_items'] - - # fields are returned anyway since no schema = return all fields - for r in resource: - self.assertTrue('location' in r) - self.assertTrue(self.domain[self.known_resource]['id_field'] in r) - self.assertTrue(self.app.config['LAST_UPDATED'] in r) - self.assertTrue(self.app.config['DATE_CREATED'] in r) - - def test_get_where_disabled(self): - self.app.config['DOMAIN'][self.known_resource]['allowed_filters'] = [] - where = 'ref == %s' % self.item_name - response, status = self.get(self.known_resource, '?where=%s' % where) - self.assert200(status) - resource = response['_items'] - self.assertEqual(len(resource), self.app.config['PAGINATION_DEFAULT']) - - def test_get_sort_comma_delimited_syntax(self): - sort = '-prog' - response, status = self.get(self.known_resource, '?sort=%s' % sort) - self.assert200(status) - - resource = response['_items'] - self.assertEqual(len(resource), self.app.config['PAGINATION_DEFAULT']) - topvalue = 100 - for i in range(len(resource)): - self.assertEqual(resource[i]['prog'], topvalue - i) - - def test_get_sort_mongo_syntax(self): - sort = '[("prog",-1)]' - response, status = self.get(self.known_resource, - '?sort=%s' % sort) - self.assert200(status) - - resource = response['_items'] - self.assertEqual(len(resource), self.app.config['PAGINATION_DEFAULT']) - topvalue = 100 - for i in range(len(resource)): - self.assertEqual(resource[i]['prog'], topvalue - i) - - def test_get_custom_sort(self): - self.app.config['QUERY_SORT'] = 'orderby' - sort = '[("prog",-1)]' - response, status = self.get(self.known_resource, '?orderby=%s' % sort) - self.assert200(status) - - resource = response['_items'] - self.assertEqual(len(resource), self.app.config['PAGINATION_DEFAULT']) - topvalue = 100 - for i in range(len(resource)): - self.assertEqual(resource[i]['prog'], topvalue - i) - - def test_get_sort_disabled(self): - self.app.config['DOMAIN'][self.known_resource]['sorting'] = False - sort = '[("prog",-1)]' - response, status = self.get(self.known_resource, '?sort=%s' % sort) - self.assert200(status) - resource = response['_items'] - self.assertEqual(len(resource), self.app.config['PAGINATION_DEFAULT']) - - # this might actually fail on very rare occurences as mongodb - # 'natural' order is not granted to return documents in insertion order - self.assertEqual(resource[0]['prog'], 0) - - def test_get_default_sort(self): - s = self.app.config['DOMAIN'][self.known_resource]['datasource'] - - # set default sort to 'prog', desc. - s['default_sort'] = [('prog', -1)] - self.app.set_defaults() - response, _ = self.get(self.known_resource) - self.assertEqual(response['_items'][0]['prog'], 100) - - # set default sort to 'prog', asc. - s['default_sort'] = [('prog', 1)] - self.app.set_defaults() - response, _ = self.get(self.known_resource) - self.assertEqual(response['_items'][0]['prog'], 0) - - def test_cache_control(self): - self.assertCacheControl(self.known_resource_url) - - def test_expires(self): - self.assertExpires(self.known_resource_url) - - def test_get(self): - response, status = self.get(self.known_resource) - self.assertGet(response, status) - - def test_get_same_collection_different_resource(self): - """ the 'users' resource is actually using the same db collection as - 'contacts'. Let's verify that base filters are being applied, and - the right amount of items/links and the correct titles etc. are being - returned. Of course 'contacts' itself has its own base filter, which - excludes the 'users' (those with a 'username' field). - """ - response, status = self.get(self.different_resource) - self.assert200(status) - - links = response['_links'] - self.assertEqual(len(links), 2) - self.assertHomeLink(links) - self.assertResourceLink(links, self.different_resource) - - resource = response['_items'] - self.assertEqual(len(resource), 2) - - for item in resource: - # 'user' title instead of original 'contact' - self.assertItem(item, self.different_resource) - - etag = item.get(self.app.config['ETAG']) - self.assertTrue(etag is not None) - - def test_documents_missing_standard_date_fields(self): - """Documents created outside the API context could be lacking the - LAST_UPDATED and/or DATE_CREATED fields. - """ - contacts = self.random_contacts(1, False) - ref = 'test_update_field' - contacts[0]['ref'] = ref - _db = self.connection[MONGO_DBNAME] - _db.contacts.insert(contacts) - where = '{"ref": "%s"}' % ref - response, status = self.get(self.known_resource, - '?where=%s' % where) - self.assert200(status) - resource = response['_items'] - self.assertEqual(len(resource), 1) - self.assertItem(resource[0], self.known_resource) - - def test_get_where_allowed_filters(self): - self.app.config['DOMAIN'][self.known_resource]['allowed_filters'] = \ - ['notreally'] - where = '{"ref": "%s"}' % self.item_name - r = self.test_client.get('%s%s' % (self.known_resource_url, - '?where=%s' % where)) - self.assert400(r.status_code) - self.assertTrue(b"'ref' not allowed" in r.get_data()) - - self.app.config['DOMAIN'][self.known_resource]['allowed_filters'] = \ - ['*'] - r = self.test_client.get('%s%s' % (self.known_resource_url, - '?where=%s' % where)) - self.assert200(r.status_code) - - def test_get_with_post_override(self): - # POST request with GET override turns into a GET - headers = [('X-HTTP-Method-Override', 'GET')] - r = self.test_client.post(self.known_resource_url, headers=headers) - response, status = self.parse_response(r) - self.assertGet(response, status) - - def test_get_custom_items(self): - self.app.config['ITEMS'] = '_documents' - response, _ = self.get(self.known_resource) - self.assertTrue('_documents' in response and '_items' not in response) - - def test_get_custom_links(self): - self.app.config['LINKS'] = '_navigation' - response, _ = self.get(self.known_resource) - self.assertTrue('_navigation' in response and '_links' not in response) - - def test_get_custom_hateoas_links(self): - def change_links(response): - response['_links'] = {'self': {'title': 'Custom', - 'href': '/custom/1'}} - self.app.on_fetched_resource_contacts += change_links - - response, _ = self.get(self.known_resource) - self.assertTrue('Custom' in response['_links']['self']['title']) - self.assertTrue('/custom/1' in response['_links']['self']['href']) - - def test_get_custom_auto_document_fields(self): - self.app.config['LAST_UPDATED'] = '_updated_on' - self.app.config['DATE_CREATED'] = '_created_on' - self.app.config['ETAG'] = '_the_etag' - response, _ = self.get(self.known_resource) - for document in response['_items']: - self.assertTrue('_updated_on' in document) - self.assertTrue('_created_on' in document) - self.assertTrue('_the_etag' in document) - - def test_get_embedded_media_validate_rest_of_fields(self): - """ test multipart/form-data resource fields that are JSON - encoded are validated correctly. #806 - """ - - self.app.config['MULTIPART_FORM_FIELDS_AS_JSON'] = True - resource_with_media = { - 'image_file': { - 'type': 'media' - }, - 'some_text': { - 'type': 'string' - }, - 'some_boolean': { - 'type': 'boolean' - }, - 'some_number': { - 'type': 'number' - }, - 'some_list': { - 'type': 'list', - 'schema': {'type': 'string'} - } - } - self.app.register_resource('res_img', {'schema': resource_with_media}) - - img = b'some_image' - - # fail on boolean validate - data = {'image_file': (BytesIO(img), 'test.txt'), - 'some_boolean': '123' - } - response, status = self.parse_response( - self.test_client.post("res_img", - data=data, - headers=[('Content-Type', - 'multipart/form-data')])) - self.assert422(status) - - # fail on number validattion - data = {'image_file': (BytesIO(img), 'test.txt'), - 'some_number': 'xyz' - } - response, status = self.parse_response( - self.test_client.post("res_img", - data=data, - headers=[('Content-Type', - 'multipart/form-data')])) - self.assert422(status) - - # fail on list validation - data = {'image_file': (BytesIO(img), 'test.txt'), - 'some_list': "true" - } - response, status = self.parse_response( - self.test_client.post("res_img", - data=data, - headers=[('Content-Type', - 'multipart/form-data')])) - self.assert422(status) - - # validate all fields correctly - data = {'image_file': (BytesIO(img), 'test.txt'), - 'some_text': '"abc"', - 'some_boolean': 'true', - 'some_number': '123', - 'some_list': "[\"abc\", \"xyz\"]" - } - response, status = self.parse_response( - self.test_client.post("res_img", - data=data, - headers=[('Content-Type', - 'multipart/form-data')])) - self.assert201(status) - self.app.config['MULTIPART_FORM_FIELDS_AS_JSON'] = False - - def test_get_embedded_media(self): - """ test that embeedded images are properly rendered and #305 is fixed. - """ - - # add a 'digital_assets' endpoint to the API - self.app.register_resource( - 'digital_assets', - {'schema': {'file': {'type': 'media'}}} - ) - - # add an 'images' endpoint to the API. this will expose the embedded - # digital assets - images = { - 'image_file': { - 'type': 'objectid', - 'data_relation': { - 'resource': 'digital_assets', - 'field': '_id', - 'embeddable': True - } - } - } - self.app.register_resource('images', {'schema': images}) - - # post an asset - asset = b'a_file' - data = {'file': (BytesIO(asset), 'test.txt')} - response, status = self.parse_response( - self.test_client.post("digital_assets", - data=data, - headers=[('Content-Type', - 'multipart/form-data')])) - self.assert201(status) - - # post a document to the 'images' endpoint. the document is referencing - # the newly posted digital asset. - data = {'image_file': ObjectId(response['_id'])} - response, status = self.parse_response( - self.test_client.post("images", data=data)) - self.assert201(status) - - # retrieve the document from the same endpoint, requesting for the - # digital asset to be embedded within the retrieved document - image_id = response['_id'] - response, status = self.parse_response( - self.test_client.get( - '%s/%s%s' % ('images', image_id, - '?embedded={"image_file": 1}'))) - self.assert200(status) - - # test that the embedded document contains the same data as orignially - # posted on the digital_asset endpoint. - returned = response['image_file']['file'] - # encodedstring will raise a DeprecationWarning under Python3.3, but - # the alternative encodebytes is not available in Python 2. - encoded = base64.encodestring(asset).decode('utf-8') - self.assertEqual(returned, encoded) - self.assertEqual(base64.decodestring(returned.encode()), asset) - - def test_get_embedded(self): - # We need to assign a `person` to our test invoice - _db = self.connection[MONGO_DBNAME] - - fake_contact = self.random_contacts(1) - fake_contact_id = _db.contacts.insert(fake_contact)[0] - _db.invoices.update({'_id': ObjectId(self.invoice_id)}, - {'$set': {'person': fake_contact_id}}) - - invoices = self.domain['invoices'] - - # Test that we get 400 if can't parse dict - embedded = 'not-a-dict' - r = self.test_client.get('%s/%s' % (invoices['url'], - '?embedded=%s' % embedded)) - self.assert400(r.status_code) - - # Test that doesn't come embedded if asking for a field that - # isn't embedded (global setting is False by default) - embedded = '{"person": 1}' - r = self.test_client.get('%s/%s' % (invoices['url'], - '?embedded=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertEqual(content['_items'][0]['person'], str(fake_contact_id)) - - # Set field to be embedded - invoices['schema']['person']['data_relation']['embeddable'] = True - - # Test that global setting applies even if field is set to embedded - invoices['embedding'] = False - r = self.test_client.get('%s/%s' % (invoices['url'], - '?embedded=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertEqual(content['_items'][0]['person'], str(fake_contact_id)) - - # Test that it works - invoices['embedding'] = True - r = self.test_client.get('%s/%s' % (invoices['url'], - '?embedded=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertTrue('location' in content['_items'][0]['person']) - - # Test that it ignores a bogus field - embedded = '{"person": 1, "not-a-real-field": 1}' - r = self.test_client.get('%s/%s' % (invoices['url'], - '?embedded=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertTrue('location' in content['_items'][0]['person']) - - # Test that it ignores a real field with a bogus value - embedded = '{"person": 1, "inv_number": "not-a-real-value"}' - r = self.test_client.get('%s/%s' % (invoices['url'], - '?embedded=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertTrue('location' in content['_items'][0]['person']) - - # Test that it works with item endpoint too - r = self.test_client.get('%s/%s/%s' % (invoices['url'], - self.invoice_id, - '?embedded=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertTrue('location' in content['person']) - - # Add new embeddable field to schema - invoices['schema']['missing-field'] = { - 'type': 'objectid', - 'data_relation': {'resource': 'contacts', 'embeddable': True} - } - - # Test that it ignores embeddable field that is missing from document - embedded = '{"missing-field": 1}' - r = self.test_client.get('%s/%s' % (invoices['url'], - '?embedded=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertFalse('missing-field' in content['_items'][0]) - - # Test default fields to be embedded - invoices['embedded_fields'] = ['person'] - r = self.test_client.get("%s/" % invoices['url']) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertTrue('location' in content['_items'][0]['person']) - - # Test that default fields are overwritten by ?embedded=...0 - embedded = '{"person": 0}' - r = self.test_client.get("%s/%s" % (invoices['url'], - '?embedded=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertFalse('location' in content['_items'][0]['person']) - - def test_get_custom_embedded(self): - self.app.config['QUERY_EMBEDDED'] = 'included' - # We need to assign a `person` to our test invoice - _db = self.connection[MONGO_DBNAME] - - fake_contact = self.random_contacts(1) - fake_contact_id = _db.contacts.insert(fake_contact)[0] - _db.invoices.update({'_id': ObjectId(self.invoice_id)}, - {'$set': {'person': fake_contact_id}}) - - invoices = self.domain['invoices'] - invoices['schema']['person']['data_relation']['embeddable'] = True - - # Test that doesn't come embedded if asking for a field that - # isn't embedded (global setting is False by default) - embedded = '{"person": 1}' - invoices['embedding'] = True - r = self.test_client.get('%s/%s' % (invoices['url'], - '?included=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertTrue('location' in content['_items'][0]['person']) - - def test_get_reference_embedded_in_subdocuments(self): - _db = self.connection[MONGO_DBNAME] - - holding_contacts = self.random_contacts(2) - holding_contact_ids = _db.contacts.insert(holding_contacts) - contacts = self.random_contacts(2) - contact_ids = _db.contacts.insert(contacts) - holding = {'departments': [{'title': 'managment', - 'members': holding_contact_ids}]} - holding_id = _db.companies.insert(holding) - company = {'holding': holding_id, - 'departments': [{'title': 'development', - 'members': contact_ids}]} - company_id = _db.companies.insert(company) - # Add a documents with no reference that should be ignored - _db.companies.insert({}) - _db.companies.insert({'departments': []}) - - companies = self.domain['companies'] - contact_ids = list(map(str, contact_ids)) - - # Test that doesn't come embedded if asking for a field that - # isn't embedded ('embeddable' is False by default) - embedded = ( - '{"departments.members": 1,' + - ' "holding": 1, "holding.departments.members": 1}') - r = self.test_client.get('%s/%s' % (companies['url'], - '?embedded=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertEqual(content['_items'][1]['departments'][0]['members'], - contact_ids) - # Set field to be embedded - department_def = companies['schema']['departments']['schema'] - member_def = department_def['schema']['members']['schema'] - member_def['data_relation']['embeddable'] = True - companies['schema']['holding']['data_relation']['embeddable'] = True - - # Test that global setting applies even if field is set to embedded - companies['embedding'] = False - r = self.test_client.get('%s/%s' % (companies['url'], - '?embedded=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertEqual(content['_items'][1]['departments'][0]['members'], - contact_ids) - - # Test that it works - companies['embedding'] = True - r = self.test_client.get('%s/%s' % (companies['url'], - '?embedded=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertTrue('location' in - content['_items'][0]['departments'][0]['members'][0]) - # Test that the second company is associated with the holding - self.assertTrue('location' in - content['_items'][1]['holding'] - ['departments'][0]['members'][0]) - - # Test that it ignores a bogus field - embedded = '{"departments.members": 1, "not-a-real-field": 1}' - r = self.test_client.get('%s/%s' % (companies['url'], - '?embedded=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertTrue('location' in - content['_items'][0]['departments'][0]['members'][0]) - - # Test that it works with item endpoint too - embedded = '{"departments.members": 1}' - r = self.test_client.get('%s/%s/%s' % (companies['url'], company_id, - '?embedded=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertTrue('location' in content['departments'][0]['members'][0]) - - # Test default fields to be embedded - companies['embedded_fields'] = ["departments.members"] - r = self.test_client.get('%s/' % companies['url']) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertTrue('location' in - content['_items'][0]['departments'][0]['members'][0]) - - # Test that default fields are overwritten by ?embedded=...0 - embedded = '{"departments.members": 0}' - r = self.test_client.get('%s/%s' % (companies['url'], - '?embedded=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertFalse('location' in - content['_items'][0]['departments'][0]['members'][0]) - - def test_get_nested_resource(self): - response, status = self.get('users/overseas') - self.assertGet(response, status, 'users_overseas') - - def test_cursor_extra_find(self): - _find = self.app.data.find - hits = {'total_hits': 0} - - def find(resource, req, sub_resource_lookup): - def extra(response): - response['_hits'] = hits - cursor = _find(resource, req, sub_resource_lookup) - cursor.extra = extra - return cursor - - self.app.data.find = find - r, status = self.get(self.known_resource) - self.assert200(status) - self.assertTrue('_hits' in r) - self.assertEqual(r['_hits'], hits) - - def test_get_resource_title(self): - # test that resource endpoints accepts custom titles. - self.app.config['DOMAIN'][self.known_resource]['resource_title'] = \ - 'new title' - response, _ = self.get(self.known_resource) - self.assertTrue('new title' in response['_links']['self']['title']) - # test that the home page accepts custom titles. - response, _ = self.get('/') - found = False - for link in response['_links']['child']: - if link['title'] == 'new title': - found = True - break - self.assertTrue(found) - - def test_get_subresource(self): - _db = self.connection[MONGO_DBNAME] - - # create random contact - fake_contact = self.random_contacts(1) - fake_contact_id = _db.contacts.insert(fake_contact)[0] - # update first invoice to reference the new contact - _db.invoices.update({'_id': ObjectId(self.invoice_id)}, - {'$set': {'person': fake_contact_id}}) - - # GET all invoices by new contact - response, status = self.get('users/%s/invoices' % fake_contact_id) - self.assert200(status) - # only 1 invoice - self.assertEqual(len(response['_items']), 1) - self.assertEqual(len(response['_links']), 2) - # which links to the right contact - self.assertEqual(response['_items'][0]['person'], str(fake_contact_id)) - - def test_get_ifmatch_disabled(self): - # when IF_MATCH is disabled no etag is present in payload - self.app.config['IF_MATCH'] = False - response, status = self.get(self.known_resource) - resource = response['_items'] - - for r in resource: - self.assertTrue(self.app.config['ETAG'] not in r) - - def test_get_ims_empty_resource(self): - # test that a GET with a If-Modified-Since on an empty resource does - # not trigger a 304 and returns a empty resource instead (#243). - - # get the resource and retrieve its IMS. - r = self.test_client.get(self.known_resource_url) - last_modified = r.headers.get('Last-Modified') - - # delete the whole resource content. - r = self.test_client.delete(self.known_resource_url) - - # send a get with a IMS header from previous GET. - r = self.test_client.get(self.known_resource_url, - headers=[('If-Modified-Since', - last_modified)]) - self.assert200(r.status_code) - self.assertEqual(json.loads(r.get_data())['_items'], []) - - def test_get_idfield_doesnt_exist(self): - # test that a non-existing id field will be silently handled when - # building HATEOAS document link (#351). - self.domain[self.known_resource]['id_field'] = 'id' - response, status = self.get(self.known_resource) - self.assert200(status) - - def test_get_invalid_idfield_cors(self): - """ test that #381 is fixed. """ - request = '/%s/badid' % self.known_resource - self.app.config['X_DOMAINS'] = '*' - r = self.test_client.get(request, headers=[('Origin', 'test.com')]) - self.assert404(r.status_code) - - def test_get_invalid_where_syntax(self): - """ test that 'where' syntax with unknown '$' operator returns 400. """ - response, status = self.get(self.known_resource, - '?where={"field": {"$foo": "bar"}}') - self.assert400(status) - - def test_get_invalid_sort_syntax(self): - """ test that invalid sort syntax returns a 400 """ - response, status = self.get(self.known_resource, '?sort=[("prog":1)]') - self.assert400(status) - - def test_get_allowed_filters_operators(self): - """ test that supported operators are not considered invalid filters - (#388). Also, test that nested filters are validated. - """ - where = '?where={"$and": [{"field1": "value1"}, {"field2": "value2"}]}' - settings = self.app.config['DOMAIN'][self.known_resource] - - # valid - settings['allowed_filters'] = ['field1', 'field2'] - response, status = self.get(self.known_resource, where) - self.assert200(status) - - # invalid - settings['allowed_filters'] = ['field2'] - response, status = self.get(self.known_resource, where) - self.assert400(status) - - def test_get_nested_filter_operators_unvalidated(self): - """ test that nested filter operators are working correctly. - """ - where = ''.join( - ('?where={"$and":[{"$or":[{"fldA":"valA"},', - '{"fldB":"valB"}]},{"fld2":"val2"}]}')) - response, status = self.get(self.known_resource, where) - self.assert200(status) - - def test_get_nested_filter_operators_validated(self): - """ test that nested filter operators are working correctly. - """ - self.app.config['VALIDATE_FILTERS'] = True - - where = ''.join( - ('?where={"$and":[{"$or":[{"fldA":"valA"},', - '{"fldB":"valB"}]},{"fld2":"val2"}]}')) - response, status = self.get(self.known_resource, where) - self.assert400(status) - - where = ''.join( - ('?where={"$and":[{"$or":[{"role":', - '["agent","client"]},{"key1":"str"}]}, {"prog":1}]}')) - response, status = self.get(self.known_resource, where) - self.assert200(status) - - def test_get_invalid_where_fields(self): - """ test that checks all fields of the where clause to be valid - resource fields according to the resource schema. - """ - self.app.config['VALIDATE_FILTERS'] = True - - # test for an outright missing/invalid field present - where = '?where={"$and": [{"bad_field": "val"}, {"fld2": "val2"}]}' - response, status = self.get(self.known_resource, where) - self.assert400(status) - - # test for resource field not validating correctly (prog is number) - where = '?where={"prog": "stringValue"}' - response, status = self.get(self.known_resource, where) - self.assert400(status) - - # test for resource field validating correctly (key1 is string) - where = '?where={"key1": "qwerty"}' - response, status = self.get(self.known_resource, where) - self.assert200(status) - - # test for nested resource field validating correctly - # (location is dict) - where = '?where={"location":{"address":"str 1","city":"SomeCity"}}' - response, status = self.get(self.known_resource, where) - self.assert200(status) - - def test_get_lookup_field_as_string(self): - # Test that a resource where 'item_lookup_field' is set to a field - # of string type and which value is castable to a ObjectId is still - # treated as a string when 'query_objectid_as_string' is set to True. - # See PR #552. - data = {'id': '507c7f79bcf86cd7994f6c0e', 'name': 'john'} - response, status = self.post('ids', data=data) - self.assert201(status) - - where = '?where={"id": "507c7f79bcf86cd7994f6c0e"}' - response, status = self.get('ids', where) - self.assert200(status) - items = response['_items'] - self.assertEqual(1, len(items)) - - def test_get_custom_idfield(self): - response, status = self.get('products') - self.assert200(status) - links = response['_links'] - self.assertEqual(2, len(links)) - self.assertHomeLink(links) - self.assertResourceLink(links, 'products') - items = response['_items'] - self.assertEqual(2, len(items)) - for item in items: - self.assertItem(item, 'products') - - def test_get_subresource_with_custom_idfield(self): - db = self.connection[MONGO_DBNAME] - parent_product_sku = db.products.find_one()['sku'] - product = { - 'sku': 'BAZ', - 'title': 'Child product', - 'parent_product': parent_product_sku - } - db.products.insert(product) - response, status = self.get('products/%s/children' % - parent_product_sku) - self.assert200(status) - self.assertEqual(len(response['_items']), 1) - self.assertEqual(len(response['_links']), 2) - self.assertEqual(response['_items'][0]['parent_product'], - parent_product_sku) - - def test_get_aggregation_endpoint(self): - - _db = self.connection[MONGO_DBNAME] - _db.aggregate_test.insert_many( - [ - {"x": 1, "tags": ["dog", "cat"]}, - {"x": 2, "tags": ["cat"]}, - {"x": 2, "tags": ["mouse", "cat", "dog"]}, - {"x": 3, "tags": []} - ] - ) - - self.app.register_resource( - 'aggregate_test', { - 'datasource': { - 'aggregation': { - 'pipeline': [ - {"$unwind": "$tags"}, - {"$group": {"_id": "$tags", "count": {"$sum": - "$field1"}}}, - {"$sort": SON([("count", -1), ("_id", -1)])} - ], - } - } - } - ) - - response, status = self.get('aggregate_test?aggregate=ciao') - self.assert400(status) - - def assertOutput(doc, count, id): - self.assertEqual(doc['count'], count) - self.assertEqual(doc['_id'], id) - - response, status = self.get('aggregate_test?aggregate={"$field1":1}') - self.assert200(status) - docs = response['_items'] - self.assertEqual(len(docs), 3) - assertOutput(docs[0], 3, 'cat') - assertOutput(docs[1], 2, 'dog') - assertOutput(docs[2], 1, 'mouse') - - response, status = self.get('aggregate_test?aggregate={"$field1":2}') - self.assert200(status) - docs = response['_items'] - self.assertEqual(len(docs), 3) - assertOutput(docs[0], 6, 'cat') - assertOutput(docs[1], 4, 'dog') - assertOutput(docs[2], 2, 'mouse') - - # this will return 0 for all documents 'count' fields as no $field1 - # will be gien with the query (actually, no query will be there at all) - response, status = self.get('aggregate_test') - self.assert200(status) - docs = response['_items'] - self.assertEqual(len(docs), 3) - self.assertEqual(docs[0]['count'], 0) - self.assertEqual(docs[1]['count'], 0) - self.assertEqual(docs[2]['count'], 0) - - # malformed field name is ignored - response, status = self.get('aggregate_test?aggregate={"field1":1}') - self.assert200(status) - - # unknown field is ignored - response, status = self.get('aggregate_test?aggregate={"$unknown":1}') - self.assert200(status) - - def test_get_aggregation_parsing(self): - - date = datetime.utcnow() - - _db = self.connection[MONGO_DBNAME] - _db.aggregate_test.insert_many( - [ - {"x": 1, "date": date}, - {"x": 2, "date": date}, - {"x": 3, "date": date}, - {"x": 4, "date": date + timedelta(days=-1)}, - ] - ) - - self.app.register_resource( - 'aggregate_test', { - 'datasource': { - 'aggregation': { - 'pipeline': [ - {"$match": {"date": {"$gte": "$date"}}} - ], - } - } - } - ) - - challenge = date.strftime(self.app.config['DATE_FORMAT']) - response, status = self.get('aggregate_test?aggregate={"$date": "%s"}' - % challenge) - self.assert200(status) - docs = response['_items'] - self.assertEqual(len(docs), 3) - - challenge = (date + timedelta(days=-1)).strftime( - self.app.config['DATE_FORMAT']) - response, status = self.get('aggregate_test?aggregate={"$date": "%s"}' - % challenge) - self.assert200(status) - docs = response['_items'] - self.assertEqual(len(docs), 4) - - def test_get_aggregation_pagination(self): - _db = self.connection[MONGO_DBNAME] - - num = 75 - _db.aggregate_test.insert_many([{'x': x} for x in range(num)]) - - self.app.register_resource( - 'aggregate_test', { - 'datasource': { - 'aggregation': { - 'pipeline': [ - {"$sort": SON([("x", -1)])} - ], - } - } - } - ) - - # first page - response, status = self.get('aggregate_test') - self.assert200(status) - - items = response['_items'] - expected_length = self.app.config['PAGINATION_DEFAULT'] - self.assertEqual(len(items), expected_length) - - item, value = 0, num - 1 - self.assertEqual(items[item]['x'], value) - item, value = expected_length - 1, num - expected_length - self.assertEqual(items[item]['x'], value) - - # second page - response, status = self.get('aggregate_test?page=2') - self.assert200(status) - - items = response['_items'] - expected_length = self.app.config['PAGINATION_DEFAULT'] - self.assertEqual(len(items), expected_length) - - item, value = 0, num - 1 - self.app.config['PAGINATION_DEFAULT'] - self.assertEqual(items[item]['x'], value) - item, value = expected_length - 1, num - expected_length * 2 - self.assertEqual(items[item]['x'], value) - - # third page - response, status = self.get('aggregate_test?page=3') - self.assert200(status) - - items = response['_items'] - expected_length = num - self.app.config['PAGINATION_DEFAULT'] * 2 - self.assertEqual(len(items), expected_length) - - item, value = 0, expected_length - 1 - self.assertEqual(items[item]['x'], value) - - item, value = expected_length - 1, 0 - self.assertEqual(items[item]['x'], 0) - - # pagination is disabled for the endpoint - self.domain['aggregate_test']['pagination'] = False - # hence we get all documents with a single request - response, status = self.get('aggregate_test') - self.assert200(status) - items = response['_items'] - self.assertEqual(len(items), num) - # and pagination requests are ignored - response, status = self.get('aggregate_test?page=2') - self.assert200(status) - items = response['_items'] - self.assertEqual(len(items), num) - - def assertGet(self, response, status, resource=None): - self.assert200(status) - - links = response['_links'] - self.assertEqual(len(links), 4) - self.assertHomeLink(links) - if not resource: - resource = self.known_resource - self.assertResourceLink(links, resource) - self.assertNextLink(links, 2) - - resource = response['_items'] - self.assertEqual(len(resource), self.app.config['PAGINATION_DEFAULT']) - - for item in resource: - self.assertItem(item, self.known_resource) - - etag = item.get(self.app.config['ETAG']) - self.assertTrue(etag is not None) - - -class TestGetItem(TestBase): - - def assertItemResponse(self, response, status, resource=None): - self.assert200(status) - self.assertTrue(self.app.config['ETAG'] in response) - links = response['_links'] - self.assertEqual(len(links), 3) - self.assertHomeLink(links) - self.assertCollectionLink(links, resource or self.known_resource) - self.assertItem(response, resource or self.known_resource) - - def test_disallowed_getitem(self): - _, status = self.get(self.empty_resource, item=self.item_id) - self.assert404(status) - - def test_getitem_by_id(self): - response, status = self.get(self.known_resource, - item=self.item_id) - self.assertItemResponse(response, status) - - response, status = self.get(self.known_resource, - item=self.unknown_item_id) - self.assert404(status) - - def test_getitem_internal_by_id(self): - with self.app.test_request_context(self.known_resource_url): - response, _, _, status = getitem_internal(self.known_resource) - self.assert200(status) - - def test_getitem_noschema(self): - self.app.config['DOMAIN'][self.known_resource]['schema'] = {} - response, status = self.get(self.known_resource, item=self.item_id) - self.assertItemResponse(response, status) - - def test_getitem_by_name(self): - response, status = self.get(self.known_resource, - item=self.item_name) - self.assertItemResponse(response, status) - response, status = self.get(self.known_resource, - item=self.unknown_item_name) - self.assert404(status) - - def test_getitem_by_name_self_href(self): - response, status = self.get(self.known_resource, - item=self.item_id) - self_href = response['_links']['self']['href'] - - response, status = self.get(self.known_resource, - item=self.item_name) - - self.assertEqual(self_href, response['_links']['self']['href']) - - def test_getitem_by_integer(self): - self.domain['contacts']['additional_lookup'] = { - 'field': 'prog' - } - self.app._add_resource_url_rules('contacts', self.domain['contacts']) - response, status = self.get(self.known_resource, - item=1) - self.assertItemResponse(response, status) - response, status = self.get(self.known_resource, - item=self.known_resource_count) - self.assert404(status) - - def test_getitem_if_modified_since(self): - self.assertIfModifiedSince(self.item_id_url) - - def test_getitem_if_none_match(self): - r = self.test_client.get(self.item_id_url) - etag = r.headers.get('ETag') - self.assertTrue(etag is not None) - - # test that ETag is compliant to RFC 7232-2.3 and #794 is fixed. - self.assertTrue(etag[0] == '"') - self.assertTrue(etag[-1] == '"') - - r = self.test_client.get(self.item_id_url, - headers=[('If-None-Match', etag)]) - self.assert304(r.status_code) - self.assertTrue(not r.get_data()) - - # test that we also support doublequote-less etags, for legacy - # reasons. See #794. - r = self.test_client.get(self.item_id_url, - headers=[('If-None-Match', - etag.replace('"', ''))]) - self.assert304(r.status_code) - self.assertTrue(not r.get_data()) - - # test that we support weak etags - weak_etag = 'W/' + etag - r = self.test_client.get(self.item_id_url, - headers=[('If-None-Match', weak_etag)]) - self.assert304(r.status_code) - self.assertTrue(not r.get_data()) - - def test_cache_control(self): - self.assertCacheControl(self.item_id_url) - - def test_expires(self): - self.assertExpires(self.item_id_url) - - def test_getitem_by_id_different_resource(self): - response, status = self.get(self.different_resource, - item=self.user_id) - self.assertItemResponse(response, status, self.different_resource) - - response, status = self.get(self.different_resource, - item=self.item_id) - self.assert404(status) - - def test_getitem_by_name_different_resource(self): - response, status = self.get(self.different_resource, - item=self.user_username) - self.assertItemResponse(response, status, self.different_resource) - response, status = self.get(self.different_resource, - item=self.unknown_item_name) - self.assert404(status) - - def test_getitem_missing_standard_date_fields(self): - """Documents created outside the API context could be lacking the - LAST_UPDATED and/or DATE_CREATED fields. - """ - contacts = self.random_contacts(1, False) - ref = 'test_update_field' - contacts[0]['ref'] = ref - _db = self.connection[MONGO_DBNAME] - _db.contacts.insert(contacts) - response, status = self.get(self.known_resource, item=ref) - self.assertItemResponse(response, status) - - def test_get_with_post_override(self): - # POST request with GET override turns into a GET - headers = [('X-HTTP-Method-Override', 'GET')] - r = self.test_client.post(self.item_id_url, headers=headers) - response, status = self.parse_response(r) - self.assertItemResponse(response, status) - - def test_getitem_embedded(self): - # We need to assign a `person` to our test invoice - _db = self.connection[MONGO_DBNAME] - - fake_contact = self.random_contacts(1) - fake_contact_id = _db.contacts.insert(fake_contact)[0] - _db.invoices.update({'_id': ObjectId(self.invoice_id)}, - {'$set': {'person': fake_contact_id}}) - - invoices = self.domain['invoices'] - - # Test that we get 400 if can't parse dict - embedded = 'not-a-dict' - r = self.test_client.get('%s/%s/%s' % (invoices['url'], - self.invoice_id, - '?embedded=%s' % embedded)) - self.assert400(r.status_code) - - # Test that doesn't come embedded if asking for a field that - # isn't embedded (global setting is True by default) - embedded = '{"person": 1}' - r = self.test_client.get('%s/%s/%s' % (invoices['url'], - self.invoice_id, - '?embedded=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertTrue(content['person'], self.item_id) - - # Set field to be embedded - invoices['schema']['person']['data_relation']['embeddable'] = True - - # Test that global setting applies even if field is set to embedded - invoices['embedding'] = False - r = self.test_client.get('%s/%s/%s' % (invoices['url'], - self.invoice_id, - '?embedded=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertTrue(content['person'], self.item_id) - - # Test that it works - invoices['embedding'] = True - r = self.test_client.get('%s/%s/%s' % (invoices['url'], - self.invoice_id, - '?embedded=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertTrue('location' in content['person']) - - # Test that it ignores a bogus field - embedded = '{"person": 1, "not-a-real-field": 1}' - r = self.test_client.get('%s/%s/%s' % (invoices['url'], - self.invoice_id, - '?embedded=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertTrue('location' in content['person']) - - # Test that it ignores a real field with a bogus value - embedded = '{"person": 1, "inv_number": "not-a-real-value"}' - r = self.test_client.get('%s/%s/%s' % (invoices['url'], - self.invoice_id, - '?embedded=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertTrue('location' in content['person']) - - # Test that it works with item endpoint too - r = self.test_client.get('%s/%s/%s' % (invoices['url'], - self.invoice_id, - '?embedded=%s' % embedded)) - self.assert200(r.status_code) - content = json.loads(r.get_data()) - self.assertTrue('location' in content['person']) - - # Test that changes to embedded document invalidate parent cache - invoice_last_modified = r.headers.get('Last-Modified') - contact_url = '%s/%s' % (self.domain['contacts']['url'], - fake_contact_id) - r = self.test_client.get(contact_url) - contact_etag = r.headers.get('Etag') - - # wait for contact and invoice updated at diff to pass 1s resolution - time.sleep(2) - changes = {'location': {'city': 'new city'}} - response, status = self.patch(contact_url, data=changes, - headers=[('If-Match', contact_etag)]) - self.assert200(status) - - invoice_url = '%s/%s/%s' % (invoices['url'], self.invoice_id, - '?embedded=%s' % embedded) - r = self.test_client.get(invoice_url, - headers=[('If-Modified-Since', - invoice_last_modified)]) - self.assert200(r.status_code) - - def test_subresource_getitem(self): - _db = self.connection[MONGO_DBNAME] - - # create random contact - fake_contact = self.random_contacts(1) - fake_contact_id = _db.contacts.insert(fake_contact)[0] - # update first invoice to reference the new contact - _db.invoices.update({'_id': ObjectId(self.invoice_id)}, - {'$set': {'person': fake_contact_id}}) - - # GET all invoices by new contact - response, status = self.get('users/%s/invoices/%s' % (fake_contact_id, - self.invoice_id)) - self.assert200(status) - self.assertEqual(response['person'], str(fake_contact_id)) - self.assertEqual(response['_id'], self.invoice_id) - - def test_getitem_ifmatch_disabled(self): - # when IF_MATCH is disabled no etag is present in payload - self.app.config['IF_MATCH'] = False - response, _ = self.get(self.known_resource, item=self.item_id) - self.assertTrue(self.app.config['ETAG'] not in response) - - def test_getitem_ifmatch_disabled_if_mod_since(self): - # Test that #239 is fixed. - # IF_MATCH is disabled and If-Modified-Since request comes through. If - # a 304 was expected, we would crash like a mofo. - self.app.config['IF_MATCH'] = False - - # IMS needs to see as recent as possible since the test db has just - # been built - header = [("If-Modified-Since", date_to_rfc1123(datetime.utcnow()))] - - r = self.test_client.get(self.item_id_url, headers=header) - self.assert304(r.status_code) - - def test_getitem_custom_auto_document_fields(self): - self.app.config['LAST_UPDATED'] = '_updated_on' - self.app.config['DATE_CREATED'] = '_created_on' - self.app.config['ETAG'] = '_the_etag' - response, _ = self.get(self.known_resource, item=self.item_id) - self.assertTrue('_updated_on' in response) - self.assertTrue('_created_on' in response) - self.assertTrue('_the_etag' in response) - - def test_getitem_projection(self): - projection = '{"prog": 1}' - r, status = self.get(self.known_resource, '?projection=%s' % - projection, item=self.item_id) - self.assert200(status) - self.assertFalse('location' in r) - self.assertFalse('role' in r) - self.assertTrue('prog' in r) - self.assertTrue(self.domain[self.known_resource]['id_field'] in r) - self.assertTrue(self.app.config['ETAG'] in r) - self.assertTrue(self.app.config['LAST_UPDATED'] in r) - self.assertTrue(self.app.config['DATE_CREATED'] in r) - self.assertTrue(r[self.app.config['LAST_UPDATED']] != self.epoch) - self.assertTrue(r[self.app.config['DATE_CREATED']] != self.epoch) - - projection = '{"prog": 0}' - r, status = self.get(self.known_resource, '?projection=%s' % - projection, item=self.item_id) - self.assert200(status) - self.assertFalse('prog' in r) - self.assertTrue('location' in r) - self.assertTrue('role' in r) - self.assertTrue(self.domain[self.known_resource]['id_field'] in r) - self.assertTrue(self.app.config['ETAG'] in r) - self.assertTrue(self.app.config['LAST_UPDATED'] in r) - self.assertTrue(self.app.config['DATE_CREATED'] in r) - self.assertTrue(r[self.app.config['LAST_UPDATED']] != self.epoch) - self.assertTrue(r[self.app.config['DATE_CREATED']] != self.epoch) - - def test_getitem_lookup_field_as_string(self): - # Test that a resource where 'item_lookup_field' is set to a field - # of string type and which value is castable to a ObjectId is still - # treated as a string when 'query_objectid_as_string' is set to True. - # See PR #552. - data = {'id': '507c7f79bcf86cd7994f6c0e', 'name': 'john'} - response, status = self.post('ids', data=data) - self.assert201(status) - response, status = self.get('ids', item='507c7f79bcf86cd7994f6c0e') - self.assert200(status) - - def test_getitem_with_custom_idfield(self): - _db = self.connection[MONGO_DBNAME] - sku = _db.products.find()[0]['sku'] - response, status = self.get('products', item=sku) - self.assertItemResponse(response, status, 'products') - - -class TestHead(TestBase): - - def test_head_home(self): - self.assertHead('/') - - def test_head_resource(self): - self.assertHead(self.known_resource_url) - - def test_head_item(self): - self.assertHead(self.item_id_url) - - def assertHead(self, url): - h = self.test_client.head(url) - r = self.test_client.get(url) - self.assertTrue(not h.data) - - if 'Expires' in r.headers: - # there's a tiny chance that the two expire values will differ by - # one second. See #316. - head_expire = str_to_date(r.headers.pop('Expires')) - get_expire = str_to_date(h.headers.pop('Expires')) - d = head_expire - get_expire - self.assertTrue(d.seconds in (0, 1)) - - self.assertEqual(r.headers, h.headers) - - -class TestEvents(TestBase): - - def setUp(self): - super(TestEvents, self).setUp() - self.devent = DummyEvent(lambda: True) - - def test_on_pre_GET_for_item(self): - self.app.on_pre_GET += self.devent - self.get_item() - self.assertEqual('contacts', self.devent.called[0]) - self.assertFalse(self.devent.called[1] is None) - - def test_on_pre_GET_item_dynamic_filter(self): - def filter_this(resource, request, lookup): - lookup["_id"] = self.item_id - self.app.on_pre_GET += filter_this - # Would normally return a 404; will return one instead. - r, s = self.parse_response(self.get_item()) - self.assert200(s) - self.assertEqual(r[self.domain[self.known_resource]['id_field']], - self.item_id) - - def test_on_pre_GET_resource_for_item(self): - self.app.on_pre_GET_contacts += self.devent - self.get_item() - self.assertFalse(self.devent.called is None) - - def test_on_pre_GET_for_resource(self): - self.app.on_pre_GET += self.devent - self.get_resource() - self.assertFalse(self.devent.called is None) - - def test_on_pre_GET_resource_dynamic_filter(self): - def filter_this(resource, request, lookup): - lookup["_id"] = self.item_id - self.app.on_pre_GET += filter_this - # Would normally return all documents; will only just one. - r, s = self.parse_response(self.get_resource()) - self.assertEqual(len(r[self.app.config['ITEMS']]), 1) - - def test_on_pre_GET_resource_dynamic_filter_12_chr_nonunicode_string(self): - # Test for bug in _mongotize(). See - # https://github.com/nicolaiarocci/eve/issues/508 - def filter_this(request, lookup): - request.args = ImmutableMultiDict( - {"where": '{"name":"Alice Brooks"}'} - ) - self.app.register_resource( - 'names', - {'schema': {'name': {'type': 'string'}}} - ) - # We want to test with a non-unicode string for 'where', so we need to - # do it with a pre_GET callback - self.app.on_pre_GET_names += filter_this - self.post('names', data={"name": "Alice Brooks"}) - r, s = self.get('names') - self.assertEqual(len(r[self.app.config['ITEMS']]), 1) - - def test_on_pre_GET_resource_for_resource(self): - self.app.on_pre_GET_contacts += self.devent - self.get_resource() - self.assertFalse(self.devent.called is None) - - def test_on_post_GET_for_item(self): - self.app.on_post_GET += self.devent - self.get_item() - self.assertFalse(self.devent.called is None) - - def test_on_post_GET_resource_for_item(self): - self.app.on_post_GET_contacts += self.devent - self.get_item() - self.assertFalse(self.devent.called is None) - - def test_on_post_GET_for_resource(self): - self.app.on_post_GET += self.devent - self.get_resource() - self.assertFalse(self.devent.called is None) - - def test_on_post_GET_resource_for_resource(self): - self.app.on_post_GET_contacts += self.devent - self.get_resource() - self.assertFalse(self.devent.called is None) - - def test_on_post_GET_homepage(self): - self.app.on_post_GET += self.devent - self.test_client.get('/') - self.assertTrue(self.devent.called[0] is None) - self.assertEqual(3, len(self.devent.called)) - - def test_on_fetched_resource(self): - self.app.on_fetched_resource += self.devent - self.get_resource() - self.assertEqual('contacts', self.devent.called[0]) - self.assertEqual( - self.app.config['PAGINATION_DEFAULT'], - len(self.devent.called[1][self.app.config['ITEMS']])) - - def test_on_fetched_resource_contacts(self): - self.app.on_fetched_resource_contacts += self.devent - self.get_resource() - self.assertEqual( - self.app.config['PAGINATION_DEFAULT'], - len(self.devent.called[0][self.app.config['ITEMS']])) - - def test_on_fetched_item(self): - self.app.on_fetched_item += self.devent - self.get_item() - self.assertEqual('contacts', self.devent.called[0]) - id_field = self.domain[self.known_resource]['id_field'] - self.assertEqual(self.item_id, str(self.devent.called[1][id_field])) - self.assertEqual(2, len(self.devent.called)) - - def test_on_fetched_item_contacts(self): - self.app.on_fetched_item_contacts += self.devent - self.get_item() - id_field = self.domain[self.known_resource]['id_field'] - self.assertEqual(self.item_id, str(self.devent.called[0][id_field])) - self.assertEqual(1, len(self.devent.called)) - - def get_resource(self): - return self.test_client.get(self.known_resource_url) - - def get_item(self, url=None): - if not url: - url = self.item_id_url - return self.test_client.get(url) diff --git a/eve/tests/methods/patch.py b/eve/tests/methods/patch.py deleted file mode 100644 index 1ff5bc957..000000000 --- a/eve/tests/methods/patch.py +++ /dev/null @@ -1,763 +0,0 @@ -import simplejson as json - -from bson import ObjectId -from eve import ETAG -from eve import ISSUES -from eve import LAST_UPDATED -from eve import STATUS -from eve import STATUS_OK -from eve.methods.patch import patch_internal -from eve.tests import TestBase -from eve.tests.test_settings import MONGO_DBNAME -from eve.tests.utils import DummyEvent - - -class TestPatch(TestBase): - - def test_patch_to_resource_endpoint(self): - _, status = self.patch(self.known_resource_url, data={}) - self.assert405(status) - - def test_readonly_resource(self): - _, status = self.patch(self.readonly_id_url, data={}) - self.assert405(status) - - def test_unknown_id(self): - _, status = self.patch(self.unknown_item_id_url, - data={"key1": 'value1'}) - self.assert404(status) - - def test_unknown_id_different_resource(self): - # patching a 'user' with a valid 'contact' id will 404 - _, status = self.patch('%s/%s/' % (self.different_resource, - self.item_id), - data={"key1": "value1"}) - self.assert404(status) - - # of course we can still patch a 'user' - _, status = self.patch('%s/%s/' % (self.different_resource, - self.user_id), - data={'key1': '{"username": "username1"}'}, - headers=[('If-Match', self.user_etag)]) - self.assert200(status) - - def test_by_name(self): - _, status = self.patch(self.item_name_url, data={'key1': 'value1'}) - self.assert405(status) - - def test_ifmatch_missing(self): - res, status = self.patch(self.item_id_url, data={'key1': 'value1'}) - self.assert428(status) - - def test_ifmatch_missing_enforce_ifmatch_disabled(self): - self.app.config['ENFORCE_IF_MATCH'] = False - r, status = self.patch(self.item_id_url, data={'key1': 'value1'}) - self.assert200(status) - self.assertTrue(ETAG in r) - - def test_ifmatch_disabled(self): - self.app.config['IF_MATCH'] = False - r, status = self.patch(self.item_id_url, data={'key1': 'value1'}) - self.assert200(status) - self.assertTrue(ETAG not in r) - - def test_ifmatch_disabled_enforce_ifmatch_disabled(self): - self.app.config['ENFORCE_IF_MATCH'] = False - self.app.config['IF_MATCH'] = False - r, status = self.patch(self.item_id_url, data={'key1': 'value1'}) - self.assert200(status) - self.assertTrue(ETAG not in r) - - def test_ifmatch_bad_etag(self): - _, status = self.patch(self.item_id_url, - data={'key1': 'value1'}, - headers=[('If-Match', 'not-quite-right')]) - self.assert412(status) - - def test_ifmatch_bad_etag_enforce_ifmatch_disabled(self): - self.app.config['ENFORCE_IF_MATCH'] = False - _, status = self.patch(self.item_id_url, - data={'key1': 'value1'}, - headers=[('If-Match', 'not-quite-right')]) - self.assert412(status) - - def test_unique_value(self): - # TODO - # for the time being we are happy with testing only Eve's custom - # validation. We rely on Cerberus' own test suite for other validation - # unit tests. This test also makes sure that response status is - # syntatically correct in case of validation issues. - # We should probably test every single case as well (seems overkill). - r, status = self.patch(self.item_id_url, - data={"ref": "%s" % self.alt_ref}, - headers=[('If-Match', self.item_etag)]) - self.assertValidationErrorStatus(status) - self.assertValidationError(r, {'ref': "value '%s' is not unique" % - self.alt_ref}) - - def test_patch_string(self): - field = "ref" - test_value = "1234567890123456789012345" - changes = {field: test_value} - r = self.perform_patch(changes) - db_value = self.compare_patch_with_get(field, r) - self.assertEqual(db_value, test_value) - - def test_patch_integer(self): - field = "prog" - test_value = 9999 - changes = {field: test_value} - r = self.perform_patch(changes) - db_value = self.compare_patch_with_get(field, r) - self.assertEqual(db_value, test_value) - - def test_patch_list_as_array(self): - field = "role" - test_value = ["vendor", "client"] - changes = {field: test_value} - r = self.perform_patch(changes) - db_value = self.compare_patch_with_get(field, r) - self.assertTrue(set(test_value).issubset(db_value)) - - def test_patch_rows(self): - field = "rows" - test_value = [ - {'sku': 'AT1234', 'price': 99}, - {'sku': 'XF9876', 'price': 9999} - ] - changes = {field: test_value} - r = self.perform_patch(changes) - db_value = self.compare_patch_with_get(field, r) - - for test_item in test_value: - self.assertTrue(test_item in db_value) - - def test_patch_list(self): - field = "alist" - test_value = ["a_string", 99] - changes = {field: test_value} - r = self.perform_patch(changes) - db_value = self.compare_patch_with_get(field, r) - self.assertEqual(db_value, test_value) - - def test_patch_dict(self): - field = "location" - test_value = {'address': 'an address', 'city': 'a city'} - changes = {field: test_value} - original_city = [] - - def keep_original_city(resource_name, updates, original): - original_city.append(original['location']['city']) - - self.app.on_update += keep_original_city - self.app.on_updated += keep_original_city - r = self.perform_patch(changes) - db_value = self.compare_patch_with_get(field, r) - self.assertEqual(db_value, test_value) - self.assertEqual(original_city[0], original_city[1]) - - def test_patch_datetime(self): - field = "born" - test_value = "Tue, 06 Nov 2012 10:33:31 GMT" - changes = {field: test_value} - r = self.perform_patch(changes) - db_value = self.compare_patch_with_get(field, r) - self.assertEqual(db_value, test_value) - - def test_patch_objectid(self): - field = "tid" - test_value = "4f71c129c88e2018d4000000" - changes = {field: test_value} - r = self.perform_patch(changes) - db_value = self.compare_patch_with_get(field, r) - self.assertEqual(db_value, test_value) - - def test_patch_null_objectid(self): - # verify that #341 is fixed. - field = "tid" - test_value = None - changes = {field: test_value} - r = self.perform_patch(changes) - db_value = self.compare_patch_with_get(field, r) - self.assertEqual(db_value, test_value) - - def test_patch_defaults(self): - field = "ref" - test_value = "1234567890123456789012345" - changes = {field: test_value} - r = self.perform_patch(changes) - self.assertRaises(KeyError, self.compare_patch_with_get, 'title', r) - - def test_patch_defaults_with_post_override(self): - field = "ref" - test_value = "1234567890123456789012345" - r = self.perform_patch_with_post_override(field, test_value) - self.assert200(r.status_code) - self.assertRaises(KeyError, self.compare_patch_with_get, 'title', - json.loads(r.get_data())) - - def test_patch_multiple_fields(self): - fields = ['ref', 'prog', 'role'] - test_values = ["9876543210987654321054321", 123, ["agent"]] - changes = {"ref": test_values[0], "prog": test_values[1], - "role": test_values[2]} - r = self.perform_patch(changes) - db_values = self.compare_patch_with_get(fields, r) - for i in range(len(db_values)): - self.assertEqual(db_values[i], test_values[i]) - - def test_patch_with_post_override(self): - # a POST request with PATCH override turns into a PATCH request - r = self.perform_patch_with_post_override('prog', 1) - self.assert200(r.status_code) - - def test_patch_internal(self): - # test that patch_internal is available and working properly. - test_field = 'ref' - test_value = "9876543210987654321098765" - data = {test_field: test_value} - with self.app.test_request_context(self.item_id_url): - r, _, _, status = patch_internal( - self.known_resource, data, concurrency_check=False, - **{'_id': self.item_id}) - db_value = self.compare_patch_with_get(test_field, r) - self.assertEqual(db_value, test_value) - self.assert200(status) - - def test_patch_etag_header(self): - # test that Etag is always included with response header. See #562. - changes = {"ref": "1234567890123456789012345"} - headers = [('Content-Type', 'application/json'), - ('If-Match', self.item_etag)] - r = self.test_client.patch(self.item_id_url, - data=json.dumps(changes), - headers=headers) - self.assertTrue('Etag' in r.headers) - - # test that ETag is compliant to RFC 7232-2.3 and #794 is fixed. - etag = r.headers['ETag'] - - self.assertTrue(etag[0] == '"') - self.assertTrue(etag[-1] == '"') - - def test_patch_etag_header_enforce_ifmatch_disabled(self): - self.app.config['ENFORCE_IF_MATCH'] = False - changes = {'ref': '1234567890123456789012345'} - headers = [('Content-Type', 'application/json'), - ('If-Match', self.item_etag)] - r, status = self.patch( - self.item_id_url, - data=json.dumps(changes), - headers=headers - ) - - self.assertTrue(ETAG in r) - self.assertTrue(self.item_etag != r[ETAG]) - - def test_patch_nested(self): - changes = {'location.city': 'a nested city', - 'location.address': 'a nested address'} - r = self.perform_patch(changes) - values = self.compare_patch_with_get('location', r) - self.assertEqual(values['city'], 'a nested city') - self.assertEqual(values['address'], 'a nested address') - - def perform_patch(self, changes): - r, status = self.patch(self.item_id_url, - data=changes, - headers=[('If-Match', self.item_etag)]) - self.assert200(status) - self.assertPatchResponse(r, self.item_id) - return r - - def perform_patch_with_post_override(self, field, value): - headers = [('X-HTTP-Method-Override', 'PATCH'), - ('If-Match', self.item_etag), - ('Content-Type', 'application/json')] - return self.test_client.post(self.item_id_url, - data=json.dumps({field: value}), - headers=headers) - - def compare_patch_with_get(self, fields, patch_response): - raw_r = self.test_client.get(self.item_id_url) - r, status = self.parse_response(raw_r) - self.assert200(status) - self.assertEqual(raw_r.headers.get('ETag').replace('"', ''), - patch_response[ETAG]) - if isinstance(fields, str): - return r[fields] - else: - return [r[field] for field in fields] - - def test_patch_allow_unknown(self): - changes = {"unknown": "unknown"} - r, status = self.patch(self.item_id_url, - data=changes, - headers=[('If-Match', self.item_etag)]) - self.assertValidationErrorStatus(status) - self.assertValidationError(r, {'unknown': 'unknown field'}) - self.app.config['DOMAIN'][self.known_resource]['allow_unknown'] = True - r, status = self.patch(self.item_id_url, - data=changes, - headers=[('If-Match', self.item_etag)]) - self.assert200(status) - self.assertPatchResponse(r, self.item_id) - - def test_patch_x_www_form_urlencoded(self): - field = "ref" - test_value = "1234567890123456789012345" - changes = {field: test_value} - headers = [('If-Match', self.item_etag)] - r, status = self.parse_response(self.test_client.patch( - self.item_id_url, data=changes, headers=headers)) - self.assert200(status) - self.assertTrue('OK' in r[STATUS]) - - def test_patch_x_www_form_urlencoded_number_serialization(self): - del(self.domain['contacts']['schema']['ref']['required']) - field = 'anumber' - test_value = 3.5 - changes = {field: test_value} - headers = [('If-Match', self.item_etag)] - r, status = self.parse_response(self.test_client.patch( - self.item_id_url, data=changes, headers=headers)) - self.assert200(status) - self.assertTrue('OK' in r[STATUS]) - - def test_patch_referential_integrity(self): - data = {"person": self.unknown_item_id} - headers = [('If-Match', self.invoice_etag)] - r, status = self.patch(self.invoice_id_url, data=data, headers=headers) - self.assertValidationErrorStatus(status) - expected = ("value '%s' must exist in resource '%s', field '%s'" % - (self.unknown_item_id, 'contacts', - self.domain['contacts']['id_field'])) - self.assertValidationError(r, {'person': expected}) - - data = {"person": self.item_id} - r, status = self.patch(self.invoice_id_url, data=data, headers=headers) - self.assert200(status) - self.assertPatchResponse(r, self.invoice_id) - - def test_patch_write_concern_success(self): - # 0 and 1 are the only valid values for 'w' on our mongod instance (1 - # is the default) - self.domain['contacts']['mongo_write_concern'] = {'w': 0} - field = "ref" - test_value = "X234567890123456789012345" - changes = {field: test_value} - _, status = self.patch(self.item_id_url, - data=changes, - headers=[('If-Match', self.item_etag)]) - self.assert200(status) - - def test_patch_write_concern_fail(self): - # should get a 500 since there's no replicaset on the mongod instance - self.domain['contacts']['mongo_write_concern'] = {'w': 2} - field = "ref" - test_value = "X234567890123456789012345" - changes = {field: test_value} - _, status = self.patch(self.item_id_url, - data=changes, - headers=[('If-Match', self.item_etag)]) - self.assert500(status) - - def test_patch_missing_standard_date_fields(self): - """Documents created outside the API context could be lacking the - LAST_UPDATED and/or DATE_CREATED fields. - """ - # directly insert a document, without DATE_CREATED e LAST_UPDATED - # values. - contacts = self.random_contacts(1, False) - ref = 'test_update_field' - contacts[0]['ref'] = ref - _db = self.connection[MONGO_DBNAME] - _db.contacts.insert(contacts) - - # now retrieve same document via API and get its etag, which is - # supposed to be computed on default DATE_CREATED and LAST_UPDATAED - # values. - response, status = self.get(self.known_resource, item=ref) - etag = response[ETAG] - _id = response['_id'] - - # attempt a PATCH with the new etag. - field = "ref" - test_value = "X234567890123456789012345" - changes = {field: test_value} - _, status = self.patch('%s/%s' % (self.known_resource_url, _id), - data=changes, headers=[('If-Match', etag)]) - self.assert200(status) - - def test_patch_subresource(self): - _db = self.connection[MONGO_DBNAME] - - # create random contact - fake_contact = self.random_contacts(1) - fake_contact_id = _db.contacts.insert(fake_contact)[0] - - # update first invoice to reference the new contact - _db.invoices.update({'_id': ObjectId(self.invoice_id)}, - {'$set': {'person': fake_contact_id}}) - - # GET all invoices by new contact - response, status = self.get('users/%s/invoices/%s' % - (fake_contact_id, self.invoice_id)) - etag = response[ETAG] - - data = {"inv_number": "new_number"} - headers = [('If-Match', etag)] - response, status = self.patch('users/%s/invoices/%s' % - (fake_contact_id, self.invoice_id), - data=data, headers=headers) - self.assert200(status) - self.assertPatchResponse(response, self.invoice_id, 'peopleinvoices') - - def test_patch_bandwidth_saver(self): - changes = {'ref': '1234567890123456789012345'} - - # bandwidth_saver is on by default - self.assertTrue(self.app.config['BANDWIDTH_SAVER']) - r = self.perform_patch(changes) - self.assertFalse('ref' in r) - db_value = self.compare_patch_with_get(self.app.config['ETAG'], r) - self.assertEqual(db_value, r[self.app.config['ETAG']]) - self.item_etag = r[self.app.config['ETAG']] - - # test return all fields (bandwidth_saver off) - self.app.config['BANDWIDTH_SAVER'] = False - r = self.perform_patch(changes) - self.assertTrue('ref' in r) - db_value = self.compare_patch_with_get(self.app.config['ETAG'], r) - self.assertEqual(db_value, r[self.app.config['ETAG']]) - - def test_patch_readonly_field_with_previous_document(self): - schema = self.domain['contacts']['schema'] - del(schema['ref']['required']) - - # disable read-only on the field so we can store a value which is - # also different form its default value. - schema['read_only_field']['readonly'] = False - changes = {'read_only_field': 'value'} - r = self.perform_patch(changes) - - # resume read-only status for the field - self.domain['contacts']['schema']['read_only_field']['readonly'] = True - - # test that if the read-only field is included with the payload and its - # value is equal to the one stored with the document, validation - # succeeds (#479). - etag = r['_etag'] - r, status = self.patch(self.item_id_url, data=changes, - headers=[('If-Match', etag)]) - self.assert200(status) - self.assertPatchResponse(r, self.item_id) - - # test that if the read-only field is included with the payload and its - # value is different from the stored document, validation fails. - etag = r['_etag'] - changes = {'read_only_field': 'another value'} - r, status = self.patch(self.item_id_url, data=changes, - headers=[('If-Match', etag)]) - self.assert422(status) - self.assertTrue('is read-only' in r['_issues']['read_only_field']) - - def test_patch_nested_document_not_overwritten(self): - """ Test that nested documents are not overwritten on PATCH and #519 - is fixed. - """ - - schema = { - 'sensor': { - "type": "dict", - "schema": { - "name": {"type": "string"}, - "lon": {"type": "float"}, - "lat": {"type": "float"}, - "value": {"type": "float", "default": 10.3}, - "dict": { - 'type': 'dict', - 'schema': { - 'string': {'type': 'string'}, - 'int': {'type': 'integer'}, - } - } - } - }, - 'test': { - 'type': 'string', - 'readonly': True, - 'default': 'default' - } - } - - self.app.config['BANDWIDTH_SAVER'] = False - self.app.register_resource('sensors', {'schema': schema}) - - changes = { - 'sensor': { - 'name': 'device_name', - 'lon': 43.4, - 'lat': 1.31, - 'dict': {'int': 99} - } - } - r, status = self.post("sensors", data=changes) - self.assert201(status) - - id, etag, value, test, int = ( - r[self.domain['sensors']['id_field']], - r[ETAG], - r['sensor']['value'], - r['test'], - r['sensor']['dict']['int'] - ) - - changes = { - 'sensor': { - 'lon': 10.0, - 'dict': {'string': 'hi'} - } - } - - r, status = self.patch( - "/%s/%s" % ('sensors', id), - data=changes, - headers=[('If-Match', etag)] - ) - self.assert200(status) - - etag, value, int = ( - r[ETAG], - r['sensor']['value'], - r['sensor']['dict']['int'] - ) - self.assertEqual(value, 10.3) - self.assertEqual(test, 'default') - self.assertEqual(int, 99) - - def test_patch_nested_document_nullable_missing(self): - schema = { - 'sensor': { - 'type': 'dict', - 'schema': { - 'name': {'type': 'string'}, - }, - 'default': None, - }, - 'other': { - 'type': 'dict', - 'schema': { - 'name': {'type': 'string'}, - }, - } - } - self.app.config['BANDWIDTH_SAVER'] = False - self.app.register_resource('sensors', {'schema': schema}) - - changes = {} - - r, status = self.post("sensors", data=changes) - self.assert201(status) - id, etag = r[self.domain['sensors']['id_field']], r[ETAG] - self.assertTrue('sensor' in r) - self.assertEqual(r['sensor'], None) - self.assertFalse('other' in r) - - changes = { - 'sensor': {'name': 'device_name'}, - 'other': {'name': 'other_name'}, - } - - r, status = self.patch( - "/%s/%s" % ('sensors', id), - data=changes, - headers=[('If-Match', etag)] - ) - self.assert200(status) - self.assertEqual(r['sensor'], {'name': 'device_name'}) - self.assertEqual(r['other'], {'name': 'other_name'}) - - def test_patch_dependent_field_on_origin_document(self): - """ Test that when patching a field which is dependent on another and - this other field is not provided with the patch but is still present - on the target document, the patch will be accepted. See #363. - """ - # this will fail as dependent field is missing even in the - # document we are trying to update. - del(self.domain['contacts']['schema']['dependency_field1']['default']) - del(self.domain['contacts']['defaults']['dependency_field1']) - changes = {'dependency_field2': 'value'} - r, status = self.patch(self.item_id_url, data=changes, - headers=[('If-Match', self.item_etag)]) - self.assert422(status) - - # update the stored document by adding dependency field. - changes = {'dependency_field1': 'value'} - r, status = self.patch(self.item_id_url, data=changes, - headers=[('If-Match', self.item_etag)]) - self.assert200(status) - - # now the field2 update will be accepted as the dependency field is - # present in the stored document already. - etag = r['_etag'] - changes = {'dependency_field2': 'value'} - r, status = self.patch(self.item_id_url, data=changes, - headers=[('If-Match', etag)]) - self.assert200(status) - - def test_patch_dependent_field_value_on_origin_document(self): - """ Test that when patching a field which is dependent on another and - this other field is not provided with the patch but is still present - on the target document, the patch will be accepted. See #363. - """ - # this will fail as dependent field is missing even in the - # document we are trying to update. - changes = {'dependency_field3': 'value'} - r, status = self.patch(self.item_id_url, data=changes, - headers=[('If-Match', self.item_etag)]) - self.assert422(status) - - # update the stored document by setting the dependency field to - # the required value. - changes = {'dependency_field1': 'value'} - r, status = self.patch(self.item_id_url, data=changes, - headers=[('If-Match', self.item_etag)]) - self.assert200(status) - - # now the field2 update will be accepted as the dependency field is - # present in the stored document already. - etag = r['_etag'] - changes = {'dependency_field3': 'value'} - r, status = self.patch(self.item_id_url, data=changes, - headers=[('If-Match', etag)]) - self.assert200(status) - - def test_id_field_in_document_fails(self): - # since v0.6 we also allow the id field to be included with the POSTed - # document, but not with PATCH since it is immutable - self.app.config['IF_MATCH'] = False - id_field = self.domain[self.known_resource]['id_field'] - data = {id_field: '55b2340538345bd048100ffe'} - r, status = self.patch(self.item_id_url, data=data) - self.assert400(status) - self.assertTrue('immutable' in r['_error']['message']) - - def test_patch_custom_idfield(self): - response, status = self.get('products?max_results=1') - product = response['_items'][0] - headers = [('If-Match', product[ETAG])] - data = {'title': 'Awesome product'} - r, status = self.patch('products/%s' % product['sku'], data=data, - headers=headers) - self.assert200(status) - - def test_patch_type_coercion(self): - schema = self.domain[self.known_resource]['schema'] - schema['aninteger']['coerce'] = lambda string: int(float(string)) - changes = {'ref': '1234567890123456789054321', 'aninteger': '42.3'} - r, status = self.patch(self.item_id_url, data=changes, - headers=[('If-Match', self.item_etag)]) - self.assert200(status) - r, status = self.get(r['_links']['self']['href']) - self.assertEqual(r['aninteger'], 42) - - def assertPatchResponse(self, response, item_id, resource=None): - id_field = self.domain[resource or self.known_resource]['id_field'] - self.assertTrue(STATUS in response) - self.assertTrue(STATUS_OK in response[STATUS]) - self.assertFalse(ISSUES in response) - self.assertTrue(id_field in response) - self.assertEqual(response[id_field], item_id) - self.assertTrue(LAST_UPDATED in response) - self.assertTrue(ETAG in response) - self.assertTrue('_links' in response) - self.assertItemLink(response['_links'], item_id) - - def patch(self, url, data, headers=[]): - headers.append(('Content-Type', 'application/json')) - r = self.test_client.patch(url, - data=json.dumps(data), - headers=headers) - return self.parse_response(r) - - -class TestEvents(TestBase): - new_ref = "0123456789012345678901234" - - def test_on_pre_PATCH(self): - devent = DummyEvent(self.before_update) - self.app.on_pre_PATCH += devent - self.patch() - self.assertEqual(self.known_resource, devent.called[0]) - self.assertEqual(3, len(devent.called)) - - def test_on_pre_PATCH_contacts(self): - devent = DummyEvent(self.before_update) - self.app.on_pre_PATCH_contacts += devent - self.patch() - self.assertEqual(2, len(devent.called)) - - def test_on_PATCH_dynamic_filter(self): - def filter_this(resource, request, lookup): - lookup["_id"] = self.unknown_item_id - self.app.on_pre_PATCH += filter_this - # Would normally patch the known document; will return 404 instead. - r, s = self.parse_response(self.patch()) - self.assert404(s) - - def test_on_post_PATCH(self): - devent = DummyEvent(self.after_update) - self.app.on_post_PATCH += devent - self.patch() - self.assertEqual(self.known_resource, devent.called[0]) - self.assertEqual(200, devent.called[2].status_code) - self.assertEqual(3, len(devent.called)) - - def test_on_post_PATCH_contacts(self): - devent = DummyEvent(self.after_update) - self.app.on_post_PATCH_contacts += devent - self.patch() - self.assertEqual(200, devent.called[1].status_code) - self.assertEqual(2, len(devent.called)) - - def test_on_update(self): - devent = DummyEvent(self.before_update) - self.app.on_update += devent - self.patch() - self.assertEqual(self.known_resource, devent.called[0]) - self.assertEqual(3, len(devent.called)) - - def test_on_update_contacts(self): - devent = DummyEvent(self.before_update) - self.app.on_update_contacts += devent - self.patch() - self.assertEqual(2, len(devent.called)) - - def test_on_updated(self): - devent = DummyEvent(self.after_update) - self.app.on_updated += devent - self.patch() - self.assertEqual(self.known_resource, devent.called[0]) - self.assertEqual(3, len(devent.called)) - - def test_on_updated_contacts(self): - devent = DummyEvent(self.after_update) - self.app.on_updated_contacts += devent - self.patch() - self.assertEqual(2, len(devent.called)) - - def before_update(self): - db = self.connection[MONGO_DBNAME] - contact = db.contacts.find_one(ObjectId(self.item_id)) - return contact['ref'] == self.item_name - - def after_update(self): - return not self.before_update() - - def patch(self): - headers = [('Content-Type', 'application/json'), - ('If-Match', self.item_etag)] - data = json.dumps({"ref": self.new_ref}) - return self.test_client.patch( - self.item_id_url, data=data, headers=headers) diff --git a/eve/tests/methods/post.py b/eve/tests/methods/post.py deleted file mode 100644 index 2e4070231..000000000 --- a/eve/tests/methods/post.py +++ /dev/null @@ -1,871 +0,0 @@ -import simplejson as json - -from eve.tests import TestBase -from eve.tests.utils import DummyEvent -from eve.tests.test_settings import MONGO_DBNAME - -from eve import STATUS_OK, LAST_UPDATED, DATE_CREATED, ISSUES, STATUS, ETAG -from eve.methods.post import post -from eve.methods.post import post_internal - - -class TestPost(TestBase): - def test_unknown_resource(self): - _, status = self.post(self.unknown_resource_url, data={}) - self.assert404(status) - - def test_readonly_resource(self): - _, status = self.post(self.readonly_resource_url, data={}) - self.assert405(status) - - def test_post_to_item_endpoint(self): - _, status = self.post(self.item_id_url, data={}) - self.assert405(status) - - def test_validation_error(self): - r, status = self.post(self.known_resource_url, data={"ref": "123"}) - self.assertValidationErrorStatus(status) - self.assertValidationError(r, {'ref': 'min length is 25'}) - - r, status = self.post(self.known_resource_url, data={"prog": 123}) - self.assertValidationErrorStatus(status) - self.assertValidationError(r, {'ref': 'required'}) - - def test_post_bulk_insert_on_disabled_bulk(self): - r, status = self.post( - self.disabled_bulk_url, - data=[{'string_field': '123'}, {'string_field': '123'}]) - self.assert400(status) - - def test_post_empty_bulk_insert(self): - r, status = self.post(self.empty_resource_url, data=[]) - self.assert400(status) - - def test_post_empty_resource(self): - data = [] - for _ in range(10): - data.append({"inv_number": self.random_string(10)}) - r, status = self.post(self.empty_resource_url, data=data) - self.assert201(status) - self.assertPostResponse(r) - - def test_post_string(self): - test_field = 'ref' - test_value = "1234567890123456789054321" - data = {test_field: test_value} - self.assertPostItem(data, test_field, test_value) - - def test_post_duplicate_key(self): - data = {'ref': '1234567890123456789054321'} - r = self.perform_post(data) - id_field = self.domain[self.known_resource]['id_field'] - item_id = r[id_field] - data = {'ref': '0123456789012345678901234', id_field: item_id} - r, status = self.post(self.known_resource_url, data=data) - self.assertEqual(status, 409) - - def test_post_integer(self): - del(self.domain['contacts']['schema']['ref']['required']) - test_field = 'prog' - test_value = 1 - data = {test_field: test_value} - self.assertPostItem(data, test_field, test_value) - - def test_post_list_as_array(self): - del(self.domain['contacts']['schema']['ref']['required']) - test_field = "role" - test_value = ["vendor", "client"] - data = {test_field: test_value} - self.assertPostItem(data, test_field, test_value) - - def test_post_rows(self): - del(self.domain['contacts']['schema']['ref']['required']) - test_field = "rows" - test_value = [ - {'sku': 'AT1234', 'price': 99}, - {'sku': 'XF9876', 'price': 9999} - ] - data = {test_field: test_value} - self.assertPostItem(data, test_field, test_value) - - def test_post_list(self): - del(self.domain['contacts']['schema']['ref']['required']) - test_field = "alist" - test_value = ["a_string", 99] - data = {test_field: test_value} - self.assertPostItem(data, test_field, test_value) - - def test_post_integer_zero(self): - del(self.domain['contacts']['schema']['ref']['required']) - test_field = "aninteger" - test_value = 0 - data = {test_field: test_value} - self.assertPostItem(data, test_field, test_value) - - def test_post_float_zero(self): - del(self.domain['contacts']['schema']['ref']['required']) - test_field = "afloat" - test_value = 0.0 - data = {test_field: test_value} - self.assertPostItem(data, test_field, test_value) - - def test_post_dict(self): - del(self.domain['contacts']['schema']['ref']['required']) - test_field = "location" - test_value = {'address': 'an address', 'city': 'a city'} - data = {test_field: test_value} - self.assertPostItem(data, test_field, test_value) - - def test_post_datetime(self): - del(self.domain['contacts']['schema']['ref']['required']) - test_field = "born" - test_value = "Tue, 06 Nov 2012 10:33:31 GMT" - data = {test_field: test_value} - self.assertPostItem(data, test_field, test_value) - - def test_post_objectid(self): - del(self.domain['contacts']['schema']['ref']['required']) - test_field = 'tid' - test_value = "50656e4538345b39dd0414f0" - data = {test_field: test_value} - self.assertPostItem(data, test_field, test_value) - - def test_post_null_objectid(self): - # verify that #341 is fixed. - del(self.domain['contacts']['schema']['ref']['required']) - test_field = 'tid' - test_value = None - data = {test_field: test_value} - self.assertPostItem(data, test_field, test_value) - - def test_post_default_value(self): - test_field = 'title' - test_value = "Mr." - data = {'ref': '9234567890123456789054321'} - self.assertPostItem(data, test_field, test_value) - - def test_post_default_value_none(self): - # default values that assimilate to None (0, '', False) were ignored - # prior to 0.1.1 - title = self.domain['contacts']['schema']['title'] - title['default'] = '' - self.app.set_defaults() - data = {"ref": "UUUUUUUUUUUUUUUUUUUUUUUUU"} - self.assertPostItem(data, 'title', '') - - title['type'] = 'integer' - title['default'] = 0 - self.app.set_defaults() - data = {"ref": "TTTTTTTTTTTTTTTTTTTTTTTTT"} - self.assertPostItem(data, 'title', 0) - - title['type'] = 'boolean' - title['default'] = False - self.app.set_defaults() - data = {"ref": "QQQQQQQQQQQQQQQQQQQQQQQQQ"} - self.assertPostItem(data, 'title', False) - - def test_multi_post_valid(self): - data = [ - {"ref": "9234567890123456789054321"}, - {"ref": "5432112345678901234567890", "role": ["agent"]}, - ] - r, status = self.post(self.known_resource_url, data=data) - self.assert201(status) - results = r['_items'] - - self.assertEqual(results[0]['_status'], 'OK') - self.assertEqual(results[1]['_status'], 'OK') - - with self.app.test_request_context(): - contacts = self.app.data.driver.db['contacts'] - r = contacts.find({"ref": "9234567890123456789054321"}).count() - self.assertTrue(r == 1) - r = contacts.find({"ref": "5432112345678901234567890"}).count() - self.assertTrue(r == 1) - - def test_multi_post_invalid(self): - data = [ - {"ref": "9234567890123456789054321"}, - {"prog": 9999}, - {"ref": "5432112345678901234567890", "role": ["agent"]}, - {"ref": self.item_ref}, - {"ref": "9234567890123456789054321", "tid": "12345678"}, - ] - r, status = self.post(self.known_resource_url, data=data) - self.assertValidationErrorStatus(status) - results = r['_items'] - - self.assertEqual(results[0]['_status'], 'OK') - self.assertEqual(results[2]['_status'], 'OK') - - self.assertValidationError(results[1], {'ref': 'required'}) - self.assertValidationError(results[3], {'ref': 'unique'}) - self.assertValidationError(results[4], {'tid': 'ObjectId'}) - - id_field = self.domain[self.known_resource]['id_field'] - self.assertTrue(id_field not in results[0]) - self.assertTrue(id_field not in results[1]) - self.assertTrue(id_field not in results[2]) - self.assertTrue(id_field not in results[3]) - - with self.app.test_request_context(): - contacts = self.app.data.driver.db['contacts'] - r = contacts.find({"prog": 9999}).count() - self.assertTrue(r == 0) - r = contacts.find({"ref": "9234567890123456789054321"}).count() - self.assertTrue(r == 0) - - def test_post_x_www_form_urlencoded(self): - test_field = "ref" - test_value = "1234567890123456789054321" - data = {test_field: test_value} - r, status = self.parse_response(self.test_client.post( - self.known_resource_url, data=data)) - self.assert201(status) - self.assertTrue('OK' in r[STATUS]) - self.assertPostResponse(r) - - def test_post_x_www_form_urlencoded_number_serialization(self): - del(self.domain['contacts']['schema']['ref']['required']) - test_field = "anumber" - test_value = 34 - data = {test_field: test_value} - r, status = self.parse_response(self.test_client.post( - self.known_resource_url, data=data)) - self.assert201(status) - self.assertTrue('OK' in r[STATUS]) - self.assertPostResponse(r) - - def test_post_referential_integrity(self): - data = {"person": self.unknown_item_id} - r, status = self.post('/invoices/', data=data) - self.assertValidationErrorStatus(status) - expected = ("value '%s' must exist in resource '%s', field '%s'" % - (self.unknown_item_id, 'contacts', - self.domain['contacts']['id_field'])) - self.assertValidationError(r, {'person': expected}) - - data = {"person": self.item_id} - r, status = self.post('/invoices/', data=data) - self.assert201(status) - self.assertPostResponse(r) - - def test_dbref_post_referential_integrity(self): - data = {"persondbref": {"$col": "contacts", - "$id": self.unknown_item_id}} - r, status = self.post('/invoices/', data=data) - self.assertValidationErrorStatus(status) - expected = ("value '%s' must exist in resource '%s', field '%s'" % - (self.unknown_item_id, 'contacts', - self.domain['contacts']['id_field'])) - - self.assertValidationError(r, {'persondbref': expected}) - - data = {"persondbref": {"$col": "contacts", "$id": self.item_id}} - r, status = self.post('/invoices/', data=data) - self.assert201(status) - self.assertPostResponse(r) - - def test_post_referential_integrity_list(self): - data = {"invoicing_contacts": [self.item_id, self.unknown_item_id]} - r, status = self.post('/invoices/', data=data) - self.assertValidationErrorStatus(status) - expected = ("value '%s' must exist in resource '%s', field '%s'" % - (self.unknown_item_id, 'contacts', - self.domain['contacts']['id_field'])) - self.assertValidationError(r, {'invoicing_contacts': expected}) - - data = {"invoicing_contacts": [self.item_id, self.item_id]} - r, status = self.post('/invoices/', data=data) - self.assert201(status) - self.assertPostResponse(r) - - def test_post_allow_unknown(self): - del(self.domain['contacts']['schema']['ref']['required']) - data = {"unknown": "unknown"} - r, status = self.post(self.known_resource_url, data=data) - self.assertValidationErrorStatus(status) - self.assertValidationError(r, {'unknown': 'unknown'}) - - # since resource settings are only set at app startup we set - # those that influence the 'allow_unknown' property by hand (so we - # don't have to re-initialize the whole app.) - settings = self.app.config['DOMAIN'][self.known_resource] - settings['allow_unknown'] = True - settings['datasource']['projection'] = None - - r, status = self.post(self.known_resource_url, data=data) - self.assert201(status) - self.assertPostResponse(r) - - # test that the unknown field is also returned with subsequent get - # requests - id = r[self.domain[self.known_resource]['id_field']] - r = self.test_client.get('%s/%s' % (self.known_resource_url, id)) - r_data = json.loads(r.get_data()) - self.assertTrue('unknown' in r_data) - self.assertEqual('unknown', r_data['unknown']) - - def test_post_with_content_type_charset(self): - test_field = 'ref' - test_value = "1234567890123456789054321" - data = {test_field: test_value} - r, status = self.post(self.known_resource_url, data=data, - content_type='application/json; charset=utf-8') - self.assert201(status) - self.assertPostResponse(r) - - def test_post_with_extra_response_fields(self): - self.domain['contacts']['extra_response_fields'] = ['ref', 'notreally'] - test_field = 'ref' - test_value = "1234567890123456789054321" - data = {test_field: test_value} - r, status = self.post(self.known_resource_url, data=data) - self.assert201(status) - self.assertTrue('ref' in r and 'notreally' not in r) - - def test_post_with_excluded_response_fields(self): - data = { - 'email': 'test@email.com', - 'password': 'password' - } - r, status = self.post('login', data=data) - self.assert201(status) - - login_id = r[self.domain['login']['id_field']] - r = self.test_client.get('%s/%s' % ('login', login_id)) - r_data = json.loads(r.get_data()) - self.assertTrue('password' not in r_data) - self.assertTrue('email' in r_data) - - def test_post_write_concern(self): - # should get a 500 since there's no replicaset on mongod test instance - self.domain['contacts']['mongo_write_concern'] = {'w': 2} - test_field = 'ref' - test_value = "1234567890123456789054321" - data = {test_field: test_value} - _, status = self.post(self.known_resource_url, data=data) - self.assert500(status) - # 0 and 1 are the only valid values for 'w' on our mongod instance - self.domain['contacts']['mongo_write_concern'] = {'w': 0} - test_value = "1234567890123456789054329" - data = {test_field: test_value} - _, status = self.post(self.known_resource_url, data=data) - self.assert201(status) - - def test_post_with_get_override(self): - # a GET request with POST override turns into a POST request. - test_field = 'ref' - test_value = "1234567890123456789054321" - data = json.dumps({test_field: test_value}) - headers = [('X-HTTP-Method-Override', 'POST'), - ('Content-Type', 'application/json')] - r = self.test_client.get(self.known_resource_url, data=data, - headers=headers) - self.assert201(r.status_code) - self.assertPostResponse(json.loads(r.get_data())) - - def test_post_list_of_objectid(self): - objectid = '50656e4538345b39dd0414f0' - del(self.domain['contacts']['schema']['ref']['required']) - data = {'id_list': ['%s' % objectid]} - r, status = self.post(self.known_resource_url, data=data) - self.assert201(status) - r, status = self.get(self.known_resource, '?where={"id_list": ' - '{"$in": ["%s"]}}' % objectid) - self.assert200(status) - self.assertTrue(len(r), 1) - self.assertTrue('%s' % objectid in r['_items'][0]['id_list']) - - def test_post_nested_dict_objectid(self): - objectid = '50656e4538345b39dd0414f0' - del(self.domain['contacts']['schema']['ref']['required']) - data = {'id_list_of_dict': [{'id': '%s' % objectid}]} - r, status = self.post(self.known_resource_url, data=data) - self.assert201(status) - r, status = self.get(self.known_resource, - '?where={"id_list_of_dict.id": ' '"%s"}' - % objectid) - self.assertTrue(len(r), 1) - self.assertTrue('%s' % objectid in - r['_items'][0]['id_list_of_dict'][0]['id']) - - def test_post_valueschema_with_objectid(self): - del(self.domain['contacts']['schema']['ref']['required']) - data = {'dict_valueschema': {'id': {'challenge': - '50656e4538345b39dd0414f0'}}} - r, status = self.post(self.known_resource_url, data=data) - self.assert201(status) - - def test_post_list_fixed_len(self): - objectid = '50656e4538345b39dd0414f0' - del(self.domain['contacts']['schema']['ref']['required']) - data = {'id_list_fixed_len': ['%s' % objectid]} - r, status = self.post(self.known_resource_url, data=data) - self.assert201(status) - r, status = self.get(self.known_resource, - '?where={"id_list_fixed_len": ' - '{"$in": ["%s"]}}' % objectid) - self.assert200(status) - self.assertTrue(len(r), 1) - self.assertTrue('%s' % objectid in r['_items'][0]['id_list_fixed_len']) - - def test_custom_issues(self): - self.app.config['ISSUES'] = 'errors' - r, status = self.post(self.known_resource_url, data={"ref": "123"}) - self.assertValidationErrorStatus(status) - self.assertTrue('errors' in r and ISSUES not in r) - - def test_custom_status(self): - self.app.config['STATUS'] = 'report' - r, status = self.post(self.known_resource_url, data={"ref": "123"}) - self.assertValidationErrorStatus(status) - self.assertTrue('report' in r and STATUS not in r) - - def test_custom_etag_update_date(self): - self.app.config['ETAG'] = '_myetag' - r, status = self.post(self.known_resource_url, - data={"ref": "1234567890123456789054321"}) - self.assert201(status) - self.assertTrue('_myetag' in r and ETAG not in r) - - def test_custom_date_updated(self): - self.app.config['LAST_UPDATED'] = '_update_date' - r, status = self.post(self.known_resource_url, - data={"ref": "1234567890123456789054321"}) - self.assert201(status) - self.assertTrue('_update_date' in r and LAST_UPDATED not in r) - - def test_subresource(self): - response, status = self.post('users/%s/invoices' % - self.item_id, data={}) - self.assert201(status) - self.assertPostResponse(response) - - invoice_id = response.get(self.domain['peopleinvoices']['id_field']) - response, status = self.get('users/%s/invoices/%s' % - (self.item_id, invoice_id)) - self.assert200(status) - self.assertEqual(response.get('person'), self.item_id) - - def test_subresource_required_ref(self): - response, status = self.post('users/%s/required_invoices' % - self.item_id, data={}) - self.assert201(status) - self.assertPostResponse(response) - - invoice_id = response.get(self.domain['required_invoices']['id_field']) - response, status = self.get('users/%s/required_invoices/%s' % - (self.item_id, invoice_id)) - self.assert200(status) - self.assertEqual(response.get('person'), self.item_id) - - def test_post_ifmatch_disabled(self): - # if IF_MATCH is disabled, then we get no etag in the payload. - self.app.config['IF_MATCH'] = False - test_field = 'ref' - test_value = "1234567890123456789054321" - data = {test_field: test_value} - r, status = self.post(self.known_resource_url, data=data) - self.assertTrue(ETAG not in r) - - def test_post_custom_idfield(self): - # Test that we can post a document with a custom id_field. - id_field = 'sku' - product = {id_field: 'FOO', 'title': 'Foobar'} - r, status = self.post('products', data=product) - self.assert201(status) - self.assertTrue(id_field in r) - self.assertItemLink(r['_links'], r[id_field]) - - def test_post_with_relation_to_custom_idfield(self): - # Test that we can post a document that relates to a resource with a - # custom id_field. - id_field = 'sku' - db = self.connection[MONGO_DBNAME] - existing_product = db.products.find_one() - product = { - id_field: 'BAR', - 'title': 'Foobar', - 'parent_product': existing_product[id_field] - } - r, status = self.post('products', data=product) - self.assert201(status) - self.assertTrue(id_field in r) - self.assertItemLink(r['_links'], r[id_field]) - r, status = self.get('products', item='BAR') - self.assertEqual(r['parent_product'], existing_product[id_field]) - - def test_post_bandwidth_saver(self): - data = {'inv_number': self.random_string(10)} - - # bandwidth_saver is on by default - self.assertTrue(self.app.config['BANDWIDTH_SAVER']) - r, status = self.post(self.empty_resource_url, data=data) - self.assert201(status) - self.assertPostResponse(r) - self.assertFalse('inv_number' in r) - etag = r[self.app.config['ETAG']] - r, status = self.get( - self.empty_resource, '', - r[self.domain[self.empty_resource]['id_field']]) - self.assertEqual(etag, r[self.app.config['ETAG']]) - - # test return all fields (bandwidth_saver off) - self.app.config['BANDWIDTH_SAVER'] = False - r, status = self.post(self.empty_resource_url, data=data) - self.assert201(status) - self.assertPostResponse(r) - self.assertTrue('inv_number' in r) - etag = r[self.app.config['ETAG']] - r, status = self.get( - self.empty_resource, '', - r[self.domain[self.empty_resource]['id_field']]) - self.assertEqual(etag, r[self.app.config['ETAG']]) - - def test_post_alternative_payload(self): - payl = {"ref": "5432112345678901234567890", "role": ["agent"]} - with self.app.test_request_context(self.known_resource_url): - r, _, _, status = post(self.known_resource, payl=payl) - self.assert201(status) - self.assertPostResponse(r) - - def test_post_dependency_fields_with_default(self): - # test that default values are resolved before validation. See #353. - del(self.domain['contacts']['schema']['ref']['required']) - test_field = 'dependency_field2' - test_value = 'a value' - data = {test_field: test_value} - self.assertPostItem(data, test_field, test_value) - - def test_post_dependency_required_fields(self): - del(self.domain['contacts']['schema']['ref']['required']) - schema = self.domain['contacts']['schema'] - schema['dependency_field3']['required'] = True - - r, status = self.post(self.known_resource_url, data={}) - self.assertValidationErrorStatus(status) - self.assertValidationError(r, {'dependency_field3': 'required'}) - - # required field dependnecy value matches the dependent field's default - # value. validation still fails since required field is still missing. - # See #665. - schema['dependency_field3']['dependencies'] = {'dependency_field1': - 'default'} - r, status = self.post(self.known_resource_url, data={}) - self.assertValidationErrorStatus(status) - self.assertValidationError(r, {'dependency_field3': 'required'}) - - r, status = self.post(self.known_resource_url, - data={'dependency_field3': 'hello'}) - self.assert201(status) - - def test_post_dependency_fields_with_values(self): - # test that dependencies values are validated correctly. See #547. - del(self.domain['contacts']['schema']['ref']['required']) - - schema = { - 'field1': { - 'required': False, - 'default': 'one' - }, - 'field2': { - 'required': True, - 'dependencies': {'field1': ['one', 'two']} - } - } - settings = { - 'RESOURCE_METHODS': ['GET', 'POST', 'DELETE'], - 'ITEM_METHODS': ['GET', 'PATCH', 'PUT', 'DELETE'], - 'schema': schema - } - self.app.register_resource('posts', settings) - - data = {"field1": "three", "field2": 7} - r, s = self.post('posts', data=data) - self.assert422(s) - - data = {"field2": 7} - r, s = self.post('posts', data=data) - self.assert201(s) - - data = {"field1": "one", "field2": 7} - r, s = self.post('posts', data=data) - self.assert201(s) - - data = {"field1": "two", "field2": 7} - r, s = self.post('posts', data=data) - self.assert201(s) - - def test_post_dependency_fields_with_subdocuments(self): - # test that dependencies with sub-document fields are properly - # validated. See #706. - del(self.domain['contacts']['schema']['ref']['required']) - - schema = { - 'field1': { - 'type': 'dict', - 'schema': { - 'address': {'type': 'string'} - } - }, - 'field2': { - 'dependencies': {'field1.address': ['one', 'two']} - } - } - settings = { - 'RESOURCE_METHODS': ['GET', 'POST', 'DELETE'], - 'ITEM_METHODS': ['GET', 'PATCH', 'PUT', 'DELETE'], - 'schema': schema - } - self.app.register_resource('endpoint', settings) - - data = {"field1": {"address": "three"}, "field2": 7} - r, s = self.post('endpoint', data=data) - self.assert422(s) - - data = {"field1": {"address": "one"}, "field2": 7} - r, s = self.post('endpoint', data=data) - self.assert201(s) - - data = {"field1": {"address": "two"}, "field2": 7} - r, s = self.post('endpoint', data=data) - self.assert201(s) - - def test_post_readonly_field_with_default(self): - # test that a read only field with a 'default' setting is correctly - # validated now that we resolve field values before validation. - del(self.domain['contacts']['schema']['ref']['required']) - test_field = 'read_only_field' - # thou shalt not pass. - test_value = 'a random value' - data = {test_field: test_value} - r, status = self.post(self.known_resource_url, data=data) - self.assertValidationErrorStatus(status) - # this will not pass even if value matches 'default' setting. - # (hey it's still a read-onlu field so you can't reset it) - test_value = 'default' - data = {test_field: test_value} - r, status = self.post(self.known_resource_url, data=data) - self.assertValidationErrorStatus(status) - - def test_post_readonly_in_dict(self): - # Test that a post with a readonly field inside a dict is properly - # validated (even if it has a defult value) - del(self.domain['contacts']['schema']['ref']['required']) - test_field = 'dict_with_read_only' - test_value = {'read_only_in_dict': 'default'} - data = {test_field: test_value} - r, status = self.post(self.known_resource_url, data=data) - self.assertValidationErrorStatus(status) - - def test_post_valueschema_dict(self): - """ make sure Cerberus#48 is fixed """ - del(self.domain['contacts']['schema']['ref']['required']) - r, status = self.post(self.known_resource_url, - data={"valueschema_dict": {"k1": "1"}}) - self.assertValidationErrorStatus(status) - issues = r[ISSUES] - self.assertTrue('valueschema_dict' in issues) - self.assertEqual(issues['valueschema_dict'], - {'k1': 'must be of integer type'}) - - r, status = self.post(self.known_resource_url, - data={"valueschema_dict": {"k1": 1}}) - self.assert201(status) - - def test_post_propertyschema_dict(self): - del(self.domain['contacts']['schema']['ref']['required']) - - r, status = self.post(self.known_resource_url, - data={"propertyschema_dict": {"aaa": 1}}) - self.assert201(status) - - r, status = self.post(self.known_resource_url, - data={"propertyschema_dict": {"AAA": "1"}}) - self.assertValidationErrorStatus(status) - - issues = r[ISSUES] - self.assertTrue('propertyschema_dict' in issues) - self.assertEqual(issues['propertyschema_dict'], - 'propertyschema_dict') - - def test_post_internal(self): - # test that post_internal is available and working properly. - test_field = 'ref' - test_value = "1234567890123456789054321" - payload = {test_field: test_value} - with self.app.test_request_context(self.known_resource_url): - r, _, _, status = post_internal(self.known_resource, payl=payload) - self.assert201(status) - - def test_post_internal_skip_validation(self): - # test that when skip_validation is active everything behaves as - # expected. Also make sure that #726 is fixed. - test_field = 'ref' - test_value = "1234567890123456789054321" - payload = {test_field: test_value} - with self.app.test_request_context(self.known_resource_url): - r, _, _, status = post_internal(self.known_resource, payl=payload, - skip_validation=True) - self.assert201(status) - - def test_post_nested(self): - del(self.domain['contacts']['schema']['ref']['required']) - data = {'location.city': 'a nested city', - 'location.address': 'a nested address'} - r, status = self.post(self.known_resource_url, data=data) - self.assert201(status) - values = self.compare_post_with_get( - r[self.domain[self.known_resource]['id_field']], - ['location']).pop() - self.assertEqual(values['city'], 'a nested city') - self.assertEqual(values['address'], 'a nested address') - - def test_post_error_as_list(self): - del(self.domain['contacts']['schema']['ref']['required']) - self.app.config['VALIDATION_ERROR_AS_LIST'] = True - data = {'unknown_field': 'a value'} - r, status = self.post(self.known_resource_url, data=data) - self.assert422(status) - error = r[ISSUES]['unknown_field'] - self.assertTrue(isinstance(error, list)) - - def test_id_field_included_with_document(self): - # since v0.6 we also allow the id field to be included with the POSTed - # document - id_field = self.domain[self.known_resource]['id_field'] - id = '55b2340538345bd048100ffe' - data = {"ref": "1234567890123456789054321", id_field: id} - r, status = self.post(self.known_resource_url, data=data) - self.assert201(status) - self.assertPostResponse(r) - self.assertEqual(r['_id'], id) - - def test_post_type_coercion(self): - schema = self.domain[self.known_resource]['schema'] - schema['aninteger']['coerce'] = lambda string: int(float(string)) - data = {'ref': '1234567890123456789054321', 'aninteger': '42.3'} - self.assertPostItem(data, 'aninteger', 42) - - def perform_post(self, data, valid_items=[0]): - r, status = self.post(self.known_resource_url, data=data) - self.assert201(status) - self.assertPostResponse(r, valid_items) - return r - - def assertPostItem(self, data, test_field, test_value): - r = self.perform_post(data) - item_id = r[self.domain[self.known_resource]['id_field']] - item_etag = r[ETAG] - db_value = self.compare_post_with_get(item_id, [test_field, ETAG]) - self.assertTrue(db_value[0] == test_value) - self.assertTrue(db_value[1] == item_etag) - - def assertPostResponse(self, response, valid_items=[0], resource=None): - if '_items' in response: - results = response['_items'] - else: - results = [response] - - id_field = self.domain[resource or self.known_resource]['id_field'] - - for i in valid_items: - item = results[i] - self.assertTrue(STATUS in item) - self.assertTrue(STATUS_OK in item[STATUS]) - self.assertFalse(ISSUES in item) - self.assertTrue(id_field in item) - self.assertTrue(LAST_UPDATED in item) - self.assertTrue('_links' in item) - self.assertItemLink(item['_links'], item[id_field]) - self.assertTrue(ETAG in item) - - def compare_post_with_get(self, item_id, fields): - raw_r = self.test_client.get("%s/%s" % (self.known_resource_url, - item_id)) - item, status = self.parse_response(raw_r) - id_field = self.domain[self.known_resource]['id_field'] - self.assert200(status) - self.assertTrue(id_field in item) - self.assertTrue(item[id_field] == item_id) - self.assertTrue(DATE_CREATED in item) - self.assertTrue(LAST_UPDATED in item) - self.assertEqual(item[DATE_CREATED], item[LAST_UPDATED]) - if isinstance(fields, list): - return [item[field] for field in fields] - else: - return item[fields] - - def post(self, url, data, headers=[], content_type='application/json'): - headers.append(('Content-Type', content_type)) - r = self.test_client.post(url, data=json.dumps(data), headers=headers) - return self.parse_response(r) - - -class TestEvents(TestBase): - new_contact_id = "0123456789012345678901234" - - def test_on_pre_POST(self): - devent = DummyEvent(self.before_insert) - self.app.on_pre_POST += devent - self.post() - self.assertFalse(devent.called is None) - - def test_on_pre_POST_contacts(self): - devent = DummyEvent(self.before_insert) - self.app.on_pre_POST_contacts += devent - self.post() - self.assertFalse(devent.called is None) - - def test_on_post_POST(self): - devent = DummyEvent(self.after_insert) - self.app.on_post_POST += devent - self.post() - self.assertEqual(devent.called[0], self.known_resource) - - def test_on_POST_post_resource(self): - devent = DummyEvent(self.after_insert) - self.app.on_post_POST_contacts += devent - self.post() - self.assertFalse(devent.called is None) - - def test_on_insert(self): - devent = DummyEvent(self.before_insert, True) - self.app.on_insert += devent - self.post() - self.assertEqual(self.known_resource, devent.called[0]) - self.assertEqual(self.new_contact_id, devent.called[1][0]['ref']) - - def test_on_insert_contacts(self): - devent = DummyEvent(self.before_insert, True) - self.app.on_insert_contacts += devent - self.post() - self.assertEqual(self.new_contact_id, devent.called[0][0]['ref']) - - def test_on_inserted(self): - devent = DummyEvent(self.after_insert, True) - self.app.on_inserted += devent - self.post() - self.assertEqual(self.known_resource, devent.called[0]) - self.assertEqual(self.new_contact_id, devent.called[1][0]['ref']) - - def test_on_inserted_contacts(self): - devent = DummyEvent(self.after_insert, True) - self.app.on_inserted_contacts += devent - self.post() - self.assertEqual(self.new_contact_id, devent.called[0][0]['ref']) - - def post(self): - headers = [('Content-Type', 'application/json')] - data = json.dumps({"ref": self.new_contact_id}) - self.test_client.post( - self.known_resource_url, data=data, headers=headers) - - def before_insert(self): - db = self.connection[MONGO_DBNAME] - return db.contacts.find_one({"ref": self.new_contact_id}) is None - - def after_insert(self): - return not self.before_insert() diff --git a/eve/tests/methods/put.py b/eve/tests/methods/put.py deleted file mode 100644 index 1f50fd06a..000000000 --- a/eve/tests/methods/put.py +++ /dev/null @@ -1,548 +0,0 @@ -import simplejson as json - -from bson import ObjectId -from bson.dbref import DBRef -from eve import ETAG -from eve import ISSUES -from eve import LAST_UPDATED -from eve import STATUS -from eve import STATUS_OK -from eve.methods.put import put_internal -from eve.tests import TestBase -from eve.tests.test_settings import MONGO_DBNAME -from eve.tests.utils import DummyEvent - - -class TestPut(TestBase): - # TODO consider making a base codebase out of 'patch' and 'put' tests - def test_put_to_resource_endpoint(self): - _, status = self.put(self.known_resource_url, data={}) - self.assert405(status) - - def test_readonly_resource(self): - _, status = self.put(self.readonly_id_url, data={}) - self.assert405(status) - - def test_by_name(self): - _, status = self.put(self.item_name_url, data={'key1': 'value1'}) - self.assert405(status) - - def test_ifmatch_missing(self): - _, status = self.put(self.item_id_url, data={'key1': 'value1'}) - self.assert428(status) - - def test_ifmatch_missing_enforce_ifmatch_disabled(self): - self.app.config['ENFORCE_IF_MATCH'] = False - - def test_ifmatch_disabled(self): - self.app.config['IF_MATCH'] = False - r, status = self.put(self.item_id_url, - data={'ref': '1234567890123456789012345'}) - self.assert200(status) - self.assertTrue(ETAG not in r) - - def test_ifmatch_disabled_enforce_ifmatch_disabled(self): - self.app.config['IF_MATCH'] = False - self.app.config['ENFORCE_IF_MATCH'] = False - r, status = self.put( - self.item_id_url, - data={'ref': '1234567890123456789012345'} - ) - self.assert200(status) - self.assertTrue(ETAG not in r) - - def test_ifmatch_bad_etag(self): - _, status = self.put(self.item_id_url, - data={'key1': 'value1'}, - headers=[('If-Match', 'not-quite-right')]) - self.assert412(status) - - def test_ifmatch_bad_etag_enforce_ifmatch_disabled(self): - self.app.config['ENFORCE_IF_MATCH'] = False - _, status = self.put( - self.item_id_url, - data={'key1': 'value1'}, - headers=[('If-Match', 'not-quite-right')] - ) - self.assert412(status) - - def test_unique_value(self): - r, status = self.put(self.item_id_url, - data={"ref": "%s" % self.alt_ref}, - headers=[('If-Match', self.item_etag)]) - self.assertValidationErrorStatus(status) - self.assertValidationError(r, {'ref': "value '%s' is not unique" % - self.alt_ref}) - - def test_allow_unknown(self): - changes = {"unknown": "unknown"} - r, status = self.put(self.item_id_url, data=changes, - headers=[('If-Match', self.item_etag)]) - self.assertValidationErrorStatus(status) - self.assertValidationError(r, {'unknown': 'unknown field'}) - self.app.config['DOMAIN'][self.known_resource]['allow_unknown'] = True - changes = {"unknown": "unknown", "ref": "1234567890123456789012345"} - r, status = self.put(self.item_id_url, data=changes, - headers=[('If-Match', self.item_etag)]) - self.assert200(status) - self.assertPutResponse(r, self.item_id) - - def test_put_x_www_form_urlencoded(self): - field = "ref" - test_value = "1234567890123456789012345" - changes = {field: test_value} - headers = [('If-Match', self.item_etag)] - r, status = self.parse_response(self.test_client.put( - self.item_id_url, data=changes, headers=headers)) - self.assert200(status) - self.assertTrue('OK' in r[STATUS]) - - def test_put_x_www_form_urlencoded_number_serialization(self): - del(self.domain['contacts']['schema']['ref']['required']) - field = 'anumber' - test_value = 41 - changes = {field: test_value} - headers = [('If-Match', self.item_etag)] - r, status = self.parse_response(self.test_client.put( - self.item_id_url, data=changes, headers=headers)) - self.assert200(status) - self.assertTrue('OK' in r[STATUS]) - - def test_put_referential_integrity(self): - data = {"person": self.unknown_item_id} - headers = [('If-Match', self.invoice_etag)] - r, status = self.put(self.invoice_id_url, data=data, headers=headers) - self.assertValidationErrorStatus(status) - expected = ("value '%s' must exist in resource '%s', field '%s'" % - (self.unknown_item_id, 'contacts', - self.domain['contacts']['id_field'])) - self.assertValidationError(r, {'person': expected}) - - data = {"person": self.item_id} - r, status = self.put(self.invoice_id_url, data=data, headers=headers) - self.assert200(status) - self.assertPutResponse(r, self.invoice_id, 'invoices') - - def test_put_referential_integrity_list(self): - data = {"invoicing_contacts": [self.item_id, self.unknown_item_id]} - headers = [('If-Match', self.invoice_etag)] - r, status = self.put(self.invoice_id_url, data=data, headers=headers) - self.assertValidationErrorStatus(status) - expected = ("value '%s' must exist in resource '%s', field '%s'" % - (self.unknown_item_id, 'contacts', - self.domain['contacts']['id_field'])) - self.assertValidationError(r, {'invoicing_contacts': expected}) - - data = {"invoicing_contacts": [self.item_id, self.item_id]} - r, status = self.put(self.invoice_id_url, data=data, headers=headers) - self.assert200(status) - self.assertPutResponse(r, self.invoice_id, 'invoices') - - def test_put_write_concern_success(self): - # 0 and 1 are the only valid values for 'w' on our mongod instance (1 - # is the default) - self.domain['contacts']['mongo_write_concern'] = {'w': 0} - field = "ref" - test_value = "X234567890123456789012345" - changes = {field: test_value} - _, status = self.put(self.item_id_url, data=changes, - headers=[('If-Match', self.item_etag)]) - self.assert200(status) - - def test_put_write_concern_fail(self): - # should get a 500 since there's no replicaset on the mongod instance - self.domain['contacts']['mongo_write_concern'] = {'w': 2} - field = "ref" - test_value = "X234567890123456789012345" - changes = {field: test_value} - _, status = self.put(self.item_id_url, data=changes, - headers=[('If-Match', self.item_etag)]) - self.assert500(status) - - def test_put_string(self): - field = "ref" - test_value = "1234567890123456789012345" - changes = {field: test_value} - r = self.perform_put(changes) - db_value = self.compare_put_with_get(field, r) - self.assertEqual(db_value, test_value) - - def test_put_with_post_override(self): - # POST request with PUT override turns into a PUT - field = "ref" - test_value = "1234567890123456789012345" - changes = {field: test_value} - headers = [('X-HTTP-Method-Override', 'PUT'), - ('If-Match', self.item_etag), - ('Content-Type', 'application/x-www-form-urlencoded')] - r = self.test_client.post(self.item_id_url, data=changes, - headers=headers) - self.assert200(r.status_code) - self.assertPutResponse(json.loads(r.get_data()), self.item_id) - - def test_put_default_value(self): - test_field = 'title' - test_value = "Mr." - data = {'ref': '9234567890123456789054321'} - r = self.perform_put(data) - db_value = self.compare_put_with_get(test_field, r) - self.assertEqual(test_value, db_value) - - def test_put_readonly_value_same(self): - data = {'ref': self.item['ref'], - 'read_only_field': self.item['read_only_field']} - r, status = self.put(self.item_id_url, - data=data, - headers=[('If-Match', self.item_etag)]) - self.assert200(status) - - def test_put_readonly_value_different(self): - field = 'read_only_field' - data = {'ref': self.item['ref'], field: 'somethingelse'} - r, status = self.put(self.item_id_url, - data=data, - headers=[('If-Match', self.item_etag)]) - self.assert422(status) - self.assertValidationError(r, {field: "field is read-only"}) - - def test_put_subresource(self): - _db = self.connection[MONGO_DBNAME] - self.app.config['BANDWIDTH_SAVER'] = False - - # create random contact - fake_contact = self.random_contacts(1) - fake_contact_id = _db.contacts.insert(fake_contact)[0] - - # update first invoice to reference the new contact - _db.invoices.update({'_id': ObjectId(self.invoice_id)}, - {'$set': {'person': fake_contact_id}}) - - # GET all invoices by new contact - response, status = self.get('users/%s/invoices/%s' % - (fake_contact_id, self.invoice_id)) - etag = response[ETAG] - - data = {"inv_number": "new_number"} - headers = [('If-Match', etag)] - response, status = self.put('users/%s/invoices/%s' % - (fake_contact_id, self.invoice_id), - data=data, headers=headers) - self.assert200(status) - self.assertPutResponse(response, self.invoice_id, 'peopleinvoices') - self.assertEqual(response.get('person'), str(fake_contact_id)) - - def test_put_dbref_subresource(self): - _db = self.connection[MONGO_DBNAME] - self.app.config['BANDWIDTH_SAVER'] = False - - # create random contact - fake_contact = self.random_contacts(1) - fake_contact_id = _db.contacts.insert(fake_contact)[0] - - # update first invoice to reference the new contact - _db.invoices.update({'_id': ObjectId(self.invoice_id)}, - {'$set': { - 'person': fake_contact_id, - 'persondbref': - DBRef("contacts", - ObjectId(fake_contact_id))} - }) - - # GET all invoices by new contact - response, status = self.get('users/%s/invoices/%s' % - (fake_contact_id, self.invoice_id)) - - self.assertEqual(response.get('persondbref')['$id'], - str(fake_contact_id)) - - etag = response[ETAG] - - data = {"inv_number": "new_number"} - headers = [('If-Match', etag)] - response, status = self.put('users/%s/invoices/%s' % - (fake_contact_id, self.invoice_id), - data=data, headers=headers) - - self.assert200(status) - self.assertPutResponse(response, self.invoice_id, 'peopleinvoices') - - def test_put_bandwidth_saver(self): - changes = {'ref': '1234567890123456789012345'} - - # bandwidth_saver is on by default - self.assertTrue(self.app.config['BANDWIDTH_SAVER']) - r = self.perform_put(changes) - self.assertFalse('ref' in r) - db_value = self.compare_put_with_get(self.app.config['ETAG'], r) - self.assertEqual(db_value, r[self.app.config['ETAG']]) - self.item_etag = r[self.app.config['ETAG']] - - # test return all fields (bandwidth_saver off) - self.app.config['BANDWIDTH_SAVER'] = False - r = self.perform_put(changes) - self.assertTrue('ref' in r) - db_value = self.compare_put_with_get(self.app.config['ETAG'], r) - self.assertEqual(db_value, r[self.app.config['ETAG']]) - - def test_put_dependency_fields_with_default(self): - # Test that if a dependency is missing but has a default value then the - # field is still accepted. See #353. - del(self.domain['contacts']['schema']['ref']['required']) - field = "dependency_field2" - test_value = "a value" - changes = {field: test_value} - r = self.perform_put(changes) - db_value = self.compare_put_with_get(field, r) - self.assertEqual(db_value, test_value) - - def test_put_dependency_fields_with_wrong_value(self): - # Test that if a dependency is not met, the put is refused - del(self.domain['contacts']['schema']['ref']['required']) - r, status = self.put(self.item_id_url, - data={'dependency_field3': 'value'}, - headers=[('If-Match', self.item_etag)]) - self.assert422(status) - r, status = self.put(self.item_id_url, - data={'dependency_field1': 'value', - 'dependency_field3': 'value'}, - headers=[('If-Match', self.item_etag)]) - self.assert200(status) - - def test_put_custom_idfield(self): - product = {'title': 'Awesome Hypercube'} - r, status = self.put('products/FOOBAR', data=product) - self.assert201(status) - - def test_put_internal(self): - # test that put_internal is available and working properly. - test_field = 'ref' - test_value = "9876543210987654321098765" - data = {test_field: test_value} - with self.app.test_request_context(self.item_id_url): - r, _, _, status = put_internal( - self.known_resource, data, concurrency_check=False, - **{'_id': self.item_id}) - db_value = self.compare_put_with_get(test_field, r) - self.assertEqual(db_value, test_value) - self.assert200(status) - - def test_put_internal_skip_validation(self): - # test that put_internal is available and working properly. - test_field = 'ref' - test_value = "9876543210987654321098765" - data = {test_field: test_value} - with self.app.test_request_context(self.item_id_url): - r, _, _, status = put_internal( - self.known_resource, data, concurrency_check=False, - skip_validation=True, **{'_id': self.item_id}) - db_value = self.compare_put_with_get(test_field, r) - self.assertEqual(db_value, test_value) - self.assert200(status) - - def test_put_etag_header(self): - # test that Etag is always includer with response header. See #562. - changes = {"ref": "1234567890123456789012345"} - headers = [('Content-Type', 'application/json'), - ('If-Match', self.item_etag)] - r = self.test_client.put(self.item_id_url, - data=json.dumps(changes), - headers=headers) - self.assertTrue('Etag' in r.headers) - - # test that ETag is compliant to RFC 7232-2.3 and #794 is fixed. - etag = r.headers['ETag'] - - self.assertTrue(etag[0] == '"') - self.assertTrue(etag[-1] == '"') - - def test_put_etag_header_enforce_ifmatch_disabled(self): - self.app.config['ENFORCE_IF_MATCH'] = False - changes = {'ref': '1234567890123456789012345'} - headers = [('Content-Type', 'application/json'), - ('If-Match', self.item_etag)] - r, status = self.put( - self.item_id_url, - data=json.dumps(changes), - headers=headers - ) - self.assertTrue(ETAG in r) - self.assertTrue(self.item_etag != r[ETAG]) - - def test_put_nested(self): - changes = { - 'ref': '1234567890123456789012345', - 'location.city': 'a nested city', - 'location.address': 'a nested address' - } - r = self.perform_put(changes) - values = self.compare_put_with_get('location', r) - self.assertEqual(values['city'], 'a nested city') - self.assertEqual(values['address'], 'a nested address') - - def test_put_creates_unexisting_document(self): - id = str(ObjectId()) - url = '%s/%s' % (self.known_resource_url, id) - id_field = self.domain[self.known_resource]['id_field'] - changes = {"ref": "1234567890123456789012345"} - r, status = self.put(url, data=changes) - # 201 is a creation (POST) response - self.assert201(status) - # new document has id_field matching the PUT endpoint - self.assertEqual(r[id_field], str(id)) - - def test_put_returns_404_on_unexisting_document(self): - self.app.config['UPSERT_ON_PUT'] = False - id = str(ObjectId()) - url = '%s/%s' % (self.known_resource_url, id) - changes = {"ref": "1234567890123456789012345"} - r, status = self.put(url, data=changes) - self.assert404(status) - - def test_put_creates_unexisting_document_with_url_as_id(self): - id = str(ObjectId()) - url = '%s/%s' % (self.known_resource_url, id) - id_field = self.domain[self.known_resource]['id_field'] - changes = {"ref": "1234567890123456789012345", - id_field: str(ObjectId())} - r, status = self.put(url, data=changes) - # 201 is a creation (POST) response - self.assert201(status) - # new document has id_field matching the PUT endpoint - # (eventual mismatching id_field in the payload is ignored/replaced) - self.assertEqual(r[id_field], str(id)) - - def test_put_creates_unexisting_document_fails_on_mismatching_id(self): - id = str(ObjectId()) - id_field = self.domain[self.known_resource]['id_field'] - changes = {"ref": "1234567890123456789012345", id_field: id} - r, status = self.put(self.item_id_url, - data=changes, - headers=[('If-Match', self.item_etag)]) - self.assert400(status) - self.assertTrue('immutable' in r['_error']['message']) - - def test_put_type_coercion(self): - schema = self.domain[self.known_resource]['schema'] - schema['aninteger']['coerce'] = lambda string: int(float(string)) - changes = {'ref': '1234567890123456789054321', 'aninteger': '42.3'} - r, status = self.put(self.item_id_url, data=changes, - headers=[('If-Match', self.item_etag)]) - self.assert200(status) - r, status = self.get(r['_links']['self']['href']) - self.assertEqual(r['aninteger'], 42) - - def perform_put(self, changes): - r, status = self.put(self.item_id_url, - data=changes, - headers=[('If-Match', self.item_etag)]) - self.assert200(status) - self.assertPutResponse(r, self.item_id) - return r - - def assertPutResponse(self, response, item_id, resource=None): - id_field = self.domain[resource or self.known_resource]['id_field'] - self.assertTrue(STATUS in response) - self.assertTrue(STATUS_OK in response[STATUS]) - self.assertFalse(ISSUES in response) - self.assertTrue(id_field in response) - self.assertEqual(response[id_field], item_id) - self.assertTrue(LAST_UPDATED in response) - self.assertTrue(ETAG in response) - self.assertTrue('_links' in response) - self.assertItemLink(response['_links'], item_id) - - def compare_put_with_get(self, fields, put_response): - raw_r = self.test_client.get(self.item_id_url) - r, status = self.parse_response(raw_r) - self.assert200(status) - self.assertEqual(raw_r.headers.get('ETag').replace('"', ''), - put_response[ETAG]) - if isinstance(fields, str): - return r[fields] - else: - return [r[field] for field in fields] - - -class TestEvents(TestBase): - new_ref = "0123456789012345678901234" - - def test_on_pre_PUT(self): - devent = DummyEvent(self.before_replace) - self.app.on_pre_PUT += devent - self.put() - self.assertEqual(self.known_resource, devent.called[0]) - self.assertEqual(3, len(devent.called)) - - def test_on_pre_PUT_contacts(self): - devent = DummyEvent(self.before_replace) - self.app.on_pre_PUT_contacts += devent - self.put() - self.assertEqual(2, len(devent.called)) - - def test_on_pre_PUT_dynamic_filter(self): - def filter_this(resource, request, lookup): - lookup["_id"] = self.unknown_item_id - self.app.on_pre_PUT += filter_this - # Would normally delete the known document; will return 404 instead. - r, s = self.parse_response(self.put()) - self.assert201(s) - - def test_on_post_PUT(self): - devent = DummyEvent(self.after_replace) - self.app.on_post_PUT += devent - self.put() - self.assertEqual(self.known_resource, devent.called[0]) - self.assertEqual(200, devent.called[2].status_code) - self.assertEqual(3, len(devent.called)) - - def test_on_post_PUT_contacts(self): - devent = DummyEvent(self.after_replace) - self.app.on_post_PUT_contacts += devent - self.put() - self.assertEqual(200, devent.called[1].status_code) - self.assertEqual(2, len(devent.called)) - - def test_on_replace(self): - devent = DummyEvent(self.before_replace) - self.app.on_replace += devent - self.put() - self.assertEqual(self.known_resource, devent.called[0]) - self.assertEqual(self.new_ref, devent.called[1]['ref']) - self.assertEqual(3, len(devent.called)) - - def test_on_replace_contacts(self): - devent = DummyEvent(self.before_replace) - self.app.on_replace_contacts += devent - self.put() - self.assertEqual(self.new_ref, devent.called[0]['ref']) - self.assertEqual(2, len(devent.called)) - - def test_on_replaced(self): - devent = DummyEvent(self.after_replace) - self.app.on_replaced += devent - self.put() - self.assertEqual(self.known_resource, devent.called[0]) - self.assertEqual(self.new_ref, devent.called[1]['ref']) - self.assertEqual(3, len(devent.called)) - - def test_on_replaced_contacts(self): - devent = DummyEvent(self.after_replace) - self.app.on_replaced_contacts += devent - self.put() - self.assertEqual(self.new_ref, devent.called[0]['ref']) - self.assertEqual(2, len(devent.called)) - - def before_replace(self): - db = self.connection[MONGO_DBNAME] - contact = db.contacts.find_one(ObjectId(self.item_id)) - return contact['ref'] == self.item_name - - def after_replace(self): - return not self.before_replace() - - def put(self): - headers = [('Content-Type', 'application/json'), - ('If-Match', self.item_etag)] - data = json.dumps({"ref": self.new_ref}) - return self.test_client.put(self.item_id_url, data=data, - headers=headers) diff --git a/eve/tests/renders.py b/eve/tests/renders.py deleted file mode 100644 index ed67cd871..000000000 --- a/eve/tests/renders.py +++ /dev/null @@ -1,301 +0,0 @@ -# -*- coding: utf-8 -*- - -from eve.tests import TestBase -from eve.utils import api_prefix -from eve.tests.test_settings import MONGO_DBNAME -import simplejson as json - - -class TestRenders(TestBase): - - def test_default_render(self): - r = self.test_client.get('/') - self.assertEqual(r.content_type, 'application/json') - - def test_json_render(self): - r = self.test_client.get('/', headers=[('Accept', 'application/json')]) - self.assertEqual(r.content_type, 'application/json') - - def test_xml_render(self): - r = self.test_client.get('/', headers=[('Accept', 'application/xml')]) - self.assertTrue('application/xml' in r.content_type) - - def test_xml_url_escaping(self): - r = self.test_client.get('%s?max_results=1' % self.known_resource_url, - headers=[('Accept', 'application/xml')]) - self.assertTrue(b'&' in r.get_data()) - - def test_xml_leaf_escaping(self): - # test that even xml leaves content is being properly escaped - - # We need to assign a `person` to our test invoice - _db = self.connection[MONGO_DBNAME] - fake_contact = self.random_contacts(1) - fake_contact[0]['ref'] = "12345 & 67890" - fake_contact_id = _db.contacts.insert(fake_contact)[0] - - r = self.test_client.get('%s/%s' % - (self.known_resource_url, fake_contact_id), - headers=[('Accept', 'application/xml')]) - self.assertTrue(b'12345 & 6789' in r.get_data()) - - def test_xml_ordered_nodes(self): - """ Test that xml nodes are ordered and #441 is addressed. - """ - r = self.test_client.get('%s?max_results=1' % self.known_resource_url, - headers=[('Accept', 'application/xml')]) - data = r.get_data() - idx1 = data.index(b'_created') - idx2 = data.index(b'_etag') - idx3 = data.index(b'_id') - idx4 = data.index(b'_updated') - self.assertTrue(idx1 < idx2 < idx3 < idx4) - idx1 = data.index(b'max_results') - idx2 = data.index(b'page') - idx3 = data.index(b'total') - self.assertTrue(idx1 < idx2 < idx3) - idx1 = data.index(b'last') - idx2 = data.index(b'next') - idx3 = data.index(b'parent') - self.assertTrue(idx1 < idx2 < idx3) - - def test_unknown_render(self): - r = self.test_client.get('/', headers=[('Accept', 'application/html')]) - self.assertEqual(r.content_type, 'application/json') - - def test_json_xml_disabled(self): - self.app.config['JSON'] = False - self.app.config['XML'] = False - r = self.test_client.get(self.known_resource_url, - headers=[('Accept', 'application/json')]) - self.assert500(r.status_code) - r = self.test_client.get(self.known_resource_url, - headers=[('Accept', 'application/xml')]) - self.assert500(r.status_code) - r = self.test_client.get(self.known_resource_url) - self.assert500(r.status_code) - - def test_json_disabled(self): - self.app.config['JSON'] = False - r = self.test_client.get(self.known_resource_url, - headers=[('Accept', 'application/json')]) - self.assertTrue('application/xml' in r.content_type) - r = self.test_client.get(self.known_resource_url, - headers=[('Accept', 'application/xml')]) - self.assertTrue('application/xml' in r.content_type) - r = self.test_client.get(self.known_resource_url) - self.assertTrue('application/xml' in r.content_type) - - def test_xml_disabled(self): - self.app.config['XML'] = False - r = self.test_client.get(self.known_resource_url, - headers=[('Accept', 'application/xml')]) - self.assertEqual(r.content_type, 'application/json') - r = self.test_client.get(self.known_resource_url, - headers=[('Accept', 'application/json')]) - self.assertEqual(r.content_type, 'application/json') - r = self.test_client.get(self.known_resource_url) - self.assertEqual(r.content_type, 'application/json') - - def test_json_keys_sorted(self): - self.app.config['JSON_SORT_KEYS'] = True - r = self.test_client.get(self.known_resource_url, - headers=[('Accept', 'application/json')]) - self.assertEqual( - json.dumps(json.loads(r.get_data()), sort_keys=True).encode(), - r.get_data() - ) - - def test_jsonp_enabled(self): - arg = "callback" - self.app.config['JSONP_ARGUMENT'] = arg - val = "JSON_CALLBACK" - r = self.test_client.get('/?%s=%s' % (arg, val)) - self.assertTrue(r.get_data().decode('utf-8').startswith(val)) - - def test_CORS(self): - # no CORS headers if Origin is not provided with the request. - r = self.test_client.get('/') - self.assertFalse('Access-Control-Allow-Origin' in r.headers) - self.assertFalse('Access-Control-Allow-Methods' in r.headers) - self.assertFalse('Access-Control-Max-Age' in r.headers) - self.assertFalse('Access-Control-Expose-Headers' in r.headers) - self.assertFalse('Access-Control-Allow-Credentials' in r.headers) - self.assert200(r.status_code) - - # test that if X_DOMAINS is set to '*', then any Origin value is - # allowed. Also test that only the Origin header included with the - # request will be returned to the client. - self.app.config['X_DOMAINS'] = '*' - r = self.test_client.get('/', headers=[('Origin', - 'http://example.com')]) - self.assert200(r.status_code) - self.assertEqual(r.headers['Access-Control-Allow-Origin'], - 'http://example.com') - self.assertEqual(r.headers['Vary'], 'Origin') - - # Given that CORS is activated with X_DOMAINS = '*', - # test that if X_ALLOW_CREDENTIALS is set to True - # then the relevant header is included in the response - self.app.config['X_ALLOW_CREDENTIALS'] = True - r = self.test_client.get('/', headers=[('Origin', - 'http://example.com')]) - self.assert200(r.status_code) - self.assertEqual(r.headers['Access-Control-Allow-Credentials'], 'true') - - # with any other non-True value, it is missing - self.app.config['X_ALLOW_CREDENTIALS'] = False - r = self.test_client.get('/', headers=[('Origin', - 'http://example.com')]) - self.assert200(r.status_code) - self.assertFalse('Access-Control-Allow-Credentials' in r.headers) - - # test that if a list is set for X_DOMAINS, then: - # 1. only list values are accepted; - # 2. only the value included with the request is returned back. - self.app.config['X_DOMAINS'] = ['http://1of2.com', 'http://2of2.com'] - r = self.test_client.get('/', headers=[('Origin', 'http://1of2.com')]) - self.assert200(r.status_code) - self.assertEqual(r.headers['Access-Control-Allow-Origin'], - 'http://1of2.com') - - r = self.test_client.get('/', headers=[('Origin', 'http://2of2.com')]) - self.assert200(r.status_code) - self.assertEqual(r.headers['Access-Control-Allow-Origin'], - 'http://2of2.com') - - r = self.test_client.get('/', headers=[('Origin', - 'http://notreally.com')]) - self.assert200(r.status_code) - self.assertEqual(r.headers['Access-Control-Allow-Origin'], '') - - # other Access-Control-Allow- headers are included. - self.assertTrue('Access-Control-Allow-Headers' in r.headers) - self.assertTrue('Access-Control-Allow-Methods' in r.headers) - self.assertTrue('Access-Control-Max-Age' in r.headers) - self.assertTrue('Access-Control-Expose-Headers' in r.headers) - - # unescaped dots of old (pre v0.7) or malicious X_DOMAINS definitions - # would be interpreted as any character, causing security issue with - # bad guy registering wwwxgithub.com to pass as www.github.com (see - # #660). - - self.app.config['X_DOMAINS'] = ['http://www.github.com'] - r = self.test_client.get('/', headers=[('Origin', - 'http://wwwxgithub.com')]) - self.assert200(r.status_code) - self.assertFalse('http://wwwxgithub.com' in - r.headers['Access-Control-Allow-Origin']) - - def test_CORS_MAX_AGE(self): - self.app.config['X_DOMAINS'] = '*' - r = self.test_client.get('/', headers=[('Origin', - 'http://example.com')]) - self.assertEqual(r.headers['Access-Control-Max-Age'], - '21600') - - self.app.config['X_MAX_AGE'] = 2000 - r = self.test_client.get('/', headers=[('Origin', - 'http://example.com')]) - self.assertEqual(r.headers['Access-Control-Max-Age'], - '2000') - - def test_CORS_OPTIONS(self, url='/', methods=None): - if methods is None: - methods = [] - - r = self.test_client.open(url, method='OPTIONS') - self.assertFalse('Access-Control-Allow-Origin' in r.headers) - self.assertFalse('Access-Control-Allow-Methods' in r.headers) - self.assertFalse('Access-Control-Max-Age' in r.headers) - self.assertFalse('Access-Control-Expose-Headers' in r.headers) - self.assertFalse('Access-Control-Allow-Credentials' in r.headers) - self.assert200(r.status_code) - - # test that if X_DOMAINS is set to '*', then any Origin value is - # allowed. Also test that only the Origin header included with the - # request will be # returned back to the client. - self.app.config['X_DOMAINS'] = '*' - r = self.test_client.open(url, method='OPTIONS', - headers=[('Origin', 'http://example.com')]) - self.assert200(r.status_code) - self.assertEqual(r.headers['Access-Control-Allow-Origin'], - 'http://example.com') - self.assertEqual(r.headers['Vary'], 'Origin') - for m in methods: - self.assertTrue(m in r.headers['Access-Control-Allow-Methods']) - - # Given that CORS is activated with X_DOMAINS = '*' - # test that if X_ALLOW_CREDENTIALS is set to True - # then the relevant header is included in the response - self.app.config['X_ALLOW_CREDENTIALS'] = True - r = self.test_client.open(url, method='OPTIONS', - headers=[('Origin', 'http://example.com')]) - self.assert200(r.status_code) - self.assertEqual(r.headers['Access-Control-Allow-Credentials'], 'true') - - # with any other non-True value, it is missing - self.app.config['X_ALLOW_CREDENTIALS'] = False - r = self.test_client.open(url, method='OPTIONS', - headers=[('Origin', 'http://example.com')]) - self.assert200(r.status_code) - self.assertFalse('Access-Control-Allow-Credentials' in r.headers) - - self.app.config['X_DOMAINS'] = ['http://1of2.com', 'http://2of2.com'] - r = self.test_client.open(url, method='OPTIONS', - headers=[('Origin', 'http://1of2.com')]) - self.assert200(r.status_code) - self.assertEqual(r.headers['Access-Control-Allow-Origin'], - 'http://1of2.com') - r = self.test_client.open(url, method='OPTIONS', - headers=[('Origin', 'http://2of2.com')]) - self.assert200(r.status_code) - self.assertEqual(r.headers['Access-Control-Allow-Origin'], - 'http://2of2.com') - - for m in methods: - self.assertTrue(m in r.headers['Access-Control-Allow-Methods']) - - self.assertTrue('Access-Control-Allow-Origin' in r.headers) - self.assertTrue('Access-Control-Max-Age' in r.headers) - self.assertTrue('Access-Control-Expose-Headers' in r.headers) - - r = self.test_client.get(url, headers=[('Origin', - 'http://not_an_example.com')]) - self.assert200(r.status_code) - self.assertEqual(r.headers['Access-Control-Allow-Origin'], '') - for m in methods: - self.assertTrue(m in r.headers['Access-Control-Allow-Methods']) - - def test_CORS_OPTIONS_resources(self): - prefix = api_prefix(self.app.config['URL_PREFIX'], - self.app.config['API_VERSION']) - - del(self.domain['peopleinvoices']) - del(self.domain['peoplerequiredinvoices']) - del(self.domain['peoplesearches']) - del(self.domain['internal_transactions']) - del(self.domain['child_products']) - for _, settings in self.app.config['DOMAIN'].items(): - # resource endpoint - url = '%s/%s/' % (prefix, settings['url']) - methods = settings['resource_methods'] + ['OPTIONS'] - self.test_CORS_OPTIONS(url, methods) - - def test_CORS_OPTIONS_item(self): - prefix = api_prefix(self.app.config['URL_PREFIX'], - self.app.config['API_VERSION']) - - url = '%s%s' % (prefix, self.item_id_url) - methods = (self.domain[self.known_resource]['resource_methods'] + - ['OPTIONS']) - self.test_CORS_OPTIONS(url, methods) - url = '%s%s/%s' % (prefix, self.known_resource_url, self.item_ref) - methods = ['GET', 'OPTIONS'] - - def test_CORS_OPTIONS_schema(self): - """ Test that CORS is also supported at SCHEMA_ENDPOINT """ - self.app.config['SCHEMA_ENDPOINT'] = 'schema' - self.app._init_schema_endpoint() - methods = ['GET', 'OPTIONS'] - self.test_CORS_OPTIONS('schema', methods) diff --git a/eve/tests/response.py b/eve/tests/response.py deleted file mode 100644 index b6c978a70..000000000 --- a/eve/tests/response.py +++ /dev/null @@ -1,101 +0,0 @@ -# -*- coding: utf-8 -*- - -from ast import literal_eval -from eve.tests import TestBase -import simplejson as json -import eve -import os - - -class TestResponse(TestBase): - - def setUp(self): - super(TestResponse, self).setUp() - self.r = self.test_client.get('/%s/' % self.empty_resource) - - def test_response_data(self): - response = None - try: - response = literal_eval(self.r.get_data().decode()) - except: - self.fail('standard response cannot be converted to a dict') - self.assertTrue(isinstance(response, dict)) - - def test_response_object(self): - response = literal_eval(self.r.get_data().decode()) - self.assertTrue(isinstance(response, dict)) - self.assertEqual(len(response), 3) - - resource = response.get('_items') - self.assertTrue(isinstance(resource, list)) - links = response.get('_links') - self.assertTrue(isinstance(links, dict)) - meta = response.get('_meta') - self.assertTrue(isinstance(meta, dict)) - - -class TestNoHateoas(TestBase): - - def setUp(self): - super(TestNoHateoas, self).setUp() - self.app.config['HATEOAS'] = False - self.domain[self.known_resource]['hateoas'] = False - - def test_get_no_hateoas_resource(self): - r = self.test_client.get(self.known_resource_url) - response = json.loads(r.get_data().decode()) - self.assertTrue(isinstance(response, dict)) - self.assertEqual(len(response['_items']), 25) - item = response['_items'][0] - self.assertTrue(isinstance(item, dict)) - self.assertTrue('_links' not in response) - - def test_get_no_hateoas_item(self): - r = self.test_client.get(self.item_id_url) - response = json.loads(r.get_data().decode()) - self.assertTrue(isinstance(response, dict)) - self.assertTrue('_links' not in response) - - def test_get_no_hateoas_homepage(self): - r = self.test_client.get('/') - self.assert200(r.status_code) - - def test_get_no_hateoas_homepage_reply(self): - r = self.test_client.get('/') - resp = json.loads(r.get_data().decode()) - self.assertEqual(resp, {}) - - self.app.config['INFO'] = '_info' - - r = self.test_client.get('/') - resp = json.loads(r.get_data().decode()) - self.assertEqual(resp['_info']['server'], 'Eve') - self.assertEqual(resp['_info']['version'], eve.__version__) - - settings_file = os.path.join(self.this_directory, 'test_version.py') - self.app = eve.Eve(settings=settings_file) - self.app.config['INFO'] = '_info' - - r = self.app.test_client().get('/v1') - resp = json.loads(r.get_data().decode()) - self.assertEqual(resp['_info']['api_version'], - self.app.config['API_VERSION']) - self.assertEqual(resp['_info']['server'], 'Eve') - self.assertEqual(resp['_info']['version'], eve.__version__) - - def test_post_no_hateoas(self): - data = {'item1': json.dumps({"ref": "1234567890123456789054321"})} - headers = [('Content-Type', 'application/x-www-form-urlencoded')] - r = self.test_client.post(self.known_resource_url, data=data, - headers=headers) - response = json.loads(r.get_data().decode()) - self.assertTrue('_links' not in response) - - def test_patch_no_hateoas(self): - data = {'item1': json.dumps({"ref": "0000000000000000000000000"})} - headers = [('Content-Type', 'application/x-www-form-urlencoded'), - ('If-Match', self.item_etag)] - r = self.test_client.patch(self.item_id_url, data=data, - headers=headers) - response = json.loads(r.get_data().decode()) - self.assertTrue('_links' not in response) diff --git a/eve/tests/test_prefix.py b/eve/tests/test_prefix.py deleted file mode 100644 index 75d7b981b..000000000 --- a/eve/tests/test_prefix.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- - -URL_PREFIX = 'prefix' -DOMAIN = {'contacts': {}} diff --git a/eve/tests/test_prefix_version.py b/eve/tests/test_prefix_version.py deleted file mode 100644 index 363397d6c..000000000 --- a/eve/tests/test_prefix_version.py +++ /dev/null @@ -1,5 +0,0 @@ -# -*- coding: utf-8 -*- - -URL_PREFIX = 'prefix' -API_VERSION = 'v1' -DOMAIN = {'contacts': {}} diff --git a/eve/tests/test_settings.py b/eve/tests/test_settings.py deleted file mode 100644 index 425b40355..000000000 --- a/eve/tests/test_settings.py +++ /dev/null @@ -1,344 +0,0 @@ -# -*- coding: utf-8 -*- -import copy - - -MONGO_HOST = 'localhost' -MONGO_PORT = 27017 -MONGO_USERNAME = MONGO1_USERNAME = 'test_user' -MONGO_PASSWORD = MONGO1_PASSWORD = 'test_pw' -MONGO_DBNAME, MONGO1_DBNAME = 'eve_test', 'eve_test1' -ID_FIELD = '_id' - -RESOURCE_METHODS = ['GET', 'POST', 'DELETE'] -ITEM_METHODS = ['GET', 'PATCH', 'DELETE', 'PUT'] -ITEM_CACHE_CONTROL = '' -ITEM_LOOKUP = True -ITEM_LOOKUP_FIELD = ID_FIELD - - -disabled_bulk = { - 'url': 'somebulkurl', - 'item_title': 'bulkdisabled', - 'bulk_enabled': False, - 'schema': { - 'string_field': { - 'type': 'string' - } - } -} - - -contacts = { - 'url': 'arbitraryurl', - 'cache_control': 'max-age=20,must-revalidate', - 'cache_expires': 20, - 'item_title': 'contact', - 'additional_lookup': { - 'url': 'regex("[\w]+")', # to be unique field - 'field': 'ref' - }, - 'datasource': {'filter': {'username': {'$exists': False}}}, - 'schema': { - 'ref': { - 'type': 'string', - 'minlength': 25, - 'maxlength': 25, - 'required': True, - 'unique': True, - }, - 'media': { - 'type': 'media' - }, - 'prog': { - 'type': 'integer' - }, - 'role': { - 'type': 'list', - 'allowed': ["agent", "client", "vendor"], - }, - 'rows': { - 'type': 'list', - 'schema': { - 'type': 'dict', - 'schema': { - 'sku': {'type': 'string', 'maxlength': 10}, - 'price': {'type': 'integer'}, - }, - }, - }, - 'alist': { - 'type': 'list', - 'items': [{'type': 'string'}, {'type': 'integer'}, ] - }, - 'location': { - 'type': 'dict', - 'schema': { - 'address': {'type': 'string'}, - 'city': {'type': 'string', 'required': True} - }, - }, - 'born': { - 'type': 'datetime', - }, - 'tid': { - 'type': 'objectid', - 'nullable': True - }, - 'title': { - 'type': 'string', - 'default': 'Mr.', - }, - 'id_list': { - 'type': 'list', - 'schema': {'type': 'objectid'} - }, - 'id_list_of_dict': { - 'type': 'list', - 'schema': {'type': 'dict', 'schema': {'id': {'type': 'objectid'}}} - }, - 'id_list_fixed_len': { - 'type': 'list', - 'items': [{'type': 'objectid'}] - }, - 'dependency_field1': { - 'type': 'string', - 'default': 'default' - }, - 'dependency_field2': { - 'type': 'string', - 'dependencies': ['dependency_field1'] - }, - 'dependency_field3': { - 'type': 'string', - 'dependencies': {'dependency_field1': 'value'} - }, - 'read_only_field': { - 'type': 'string', - 'default': 'default', - 'readonly': True - }, - 'dict_with_read_only': { - 'type': 'dict', - 'schema': { - 'read_only_in_dict': { - 'type': 'string', - 'default': 'default', - 'readonly': True - } - } - }, - 'key1': { - 'type': 'string', - }, - 'propertyschema_dict': { - 'type': 'dict', - 'propertyschema': {'type': 'string', 'regex': '[a-z]+'} - }, - 'valueschema_dict': { - 'type': 'dict', - 'valueschema': {'type': 'integer'} - }, - 'aninteger': { - 'type': 'integer', - }, - 'afloat': { - 'type': 'float', - }, - 'anumber': { - 'type': 'number' - }, - 'dict_valueschema': { - 'type': 'dict', - 'valueschema': { - 'type': 'dict', - 'schema': { - 'challenge': {'type': 'objectid'} - } - } - } - } -} - -users = copy.deepcopy(contacts) -users['url'] = 'users' -users['datasource'] = {'source': 'contacts', - 'filter': {'username': {'$exists': True}}, - 'projection': {'username': 1, 'ref': 1}} -users['schema']['username'] = {'type': 'string', 'required': True} -users['resource_methods'] = ['DELETE', 'POST', 'GET'] -users['item_title'] = 'user' -users['additional_lookup']['field'] = 'username' - -invoices = { - 'schema': { - 'inv_number': {'type': 'string'}, - 'person': { - 'type': 'objectid', - 'data_relation': {'resource': 'contacts'} - }, - 'invoicing_contacts': { - 'type': 'list', - 'data_relation': {'resource': 'contacts'} - }, - 'persondbref': { - 'type': 'dbref', - 'data_relation': {'resource': 'contacts'} - }, - } -} - -# This resource is used to test app initialization when using resource -# level versioning -versioned_invoices = copy.deepcopy(invoices) -versioned_invoices['versioning'] = True - -# This resource is used to test subresources that have a reference/objectid -# field that is set to be required. -required_invoices = copy.deepcopy(invoices) -required_invoices['schema']['person']['required'] = True - -companies = { - 'item_title': 'company', - 'schema': { - 'departments': { - 'type': 'list', - 'schema': { - 'type': 'dict', - 'schema': { - 'title': {'type': 'string'}, - 'members': { - 'type': 'list', - 'schema': { - 'type': 'objectid', - 'data_relation': {'resource': 'contacts'}, - } - } - } - } - }, - 'holding': { - 'type': 'objectid', - 'data_relation': {'resource': 'companies'}, - } - } -} - -users_overseas = copy.deepcopy(users) -users_overseas['url'] = 'users/overseas' -users_overseas['datasource'] = {'source': 'contacts'} - -payments = { - 'resource_methods': ['GET'], - 'item_methods': ['GET'], -} - -empty = copy.deepcopy(invoices) - -user_restricted_access = copy.deepcopy(contacts) -user_restricted_access['url'] = 'restricted' -user_restricted_access['datasource'] = {'source': 'contacts'} - -users_invoices = copy.deepcopy(invoices) -users_invoices['url'] = 'users//invoices' -users_invoices['datasource'] = {'source': 'invoices'} - -users_required_invoices = copy.deepcopy(required_invoices) -users_required_invoices['url'] =\ - 'users//required_invoices' -users_required_invoices['datasource'] = {'source': 'required_invoices'} - -users_searches = copy.deepcopy(invoices) -users_searches['datasource'] = {'source': 'invoices'} -users_searches['url'] = \ - 'users//saved_searches' - -internal_transactions = { - 'resource_methods': ['GET'], - 'item_methods': ['GET'], - 'internal_resource': True -} - -ids = { - 'query_objectid_as_string': True, - 'item_lookup_field': 'id', - 'resource_methods': ['POST', 'GET'], - 'schema': { - 'id': {'type': 'string'}, - 'name': {'type': 'string'} - } -} - -login = { - 'item_title': 'login', - 'url': 'login', - 'datasource': { - 'projection': { - 'password': 0 - } - }, - 'schema': { - 'email': { - 'type': 'string', - 'required': True, - 'unique': True - }, - 'password': { - 'type': 'string', - 'required': True - } - } -} - -# This resource is used to test resource-specific id fields. -products = { - 'id_field': 'sku', - 'item_lookup_field': 'sku', - 'item_url': 'regex("[A-Z]+")', - 'schema': { - 'sku': { - 'type': 'string', - 'maxlength': 16 - }, - 'title': { - 'type': 'string', - 'minlength': 4, - 'maxlength': 32 - }, - 'parent_product': { - 'type': 'string', - 'data_relation': {'resource': 'products'} - } - } -} -child_products = copy.deepcopy(products) -child_products['url'] = 'products//children' -child_products['datasource'] = {'source': 'products'} - -exclusion = copy.deepcopy(contacts) -exclusion['url'] = 'exclusion' -exclusion['soft_delete'] = True -exclusion['datasource']['source'] = 'contacts' -exclusion['datasource']['projection'] = {'int': 0} - -DOMAIN = { - 'disabled_bulk': disabled_bulk, - 'contacts': contacts, - 'users': users, - 'users_overseas': users_overseas, - 'invoices': invoices, - 'versioned_invoices': versioned_invoices, - 'required_invoices': required_invoices, - 'payments': payments, - 'empty': empty, - 'restricted': user_restricted_access, - 'peopleinvoices': users_invoices, - 'peoplerequiredinvoices': users_required_invoices, - 'peoplesearches': users_searches, - 'companies': companies, - 'internal_transactions': internal_transactions, - 'ids': ids, - 'login': login, - 'products': products, - 'child_products': child_products, - 'exclusion': exclusion, -} diff --git a/eve/tests/test_version.py b/eve/tests/test_version.py deleted file mode 100644 index 19e6e336e..000000000 --- a/eve/tests/test_version.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- - -API_VERSION = 'v1' -DOMAIN = {'contacts': {}} diff --git a/eve/tests/utils.py b/eve/tests/utils.py deleted file mode 100644 index fc2a66a50..000000000 --- a/eve/tests/utils.py +++ /dev/null @@ -1,307 +0,0 @@ -# -*- coding: utf-8 -*- - -import copy -import hashlib -from bson.json_util import dumps -from datetime import datetime, timedelta -from eve.tests import TestBase -from eve.utils import parse_request, str_to_date, config, weak_date, \ - date_to_str, querydef, document_etag, extract_key_values, \ - debug_error_message, validate_filters - - -class TestUtils(TestBase): - """ collection, document and home_link methods (and resource_uri, which is - used by all of them) are tested in 'tests.methods' since we need an active - flaskapp context - """ - - def setUp(self): - super(TestUtils, self).setUp() - self.dt_fmt = config.DATE_FORMAT - self.datestr = 'Tue, 18 Sep 2012 10:12:30 GMT' - self.valid = datetime.strptime(self.datestr, self.dt_fmt) - self.etag = '56eaadbbd9fa287e7270cf13a41083c94f52ab9b' - - def test_parse_request_where(self): - self.app.config['DOMAIN'][self.known_resource]['allowed_filters'] = \ - ['ref'] - with self.app.test_request_context(): - self.assertEqual(parse_request(self.known_resource).where, None) - with self.app.test_request_context('/?where=hello'): - self.assertEqual(parse_request(self.known_resource).where, 'hello') - - def test_parse_request_sort(self): - with self.app.test_request_context(): - self.assertEqual(parse_request(self.known_resource).sort, None) - with self.app.test_request_context('/?sort=hello'): - self.assertEqual(parse_request(self.known_resource).sort, 'hello') - - def test_parse_request_page(self): - with self.app.test_request_context(): - self.assertEqual(parse_request(self.known_resource).page, 1) - with self.app.test_request_context('/?page=2'): - self.assertEqual(parse_request(self.known_resource).page, 2) - with self.app.test_request_context('/?page=-1'): - self.assertEqual(parse_request(self.known_resource).page, 1) - with self.app.test_request_context('/?page=0'): - self.assertEqual(parse_request(self.known_resource).page, 1) - with self.app.test_request_context('/?page=1.1'): - self.assertEqual(parse_request(self.known_resource).page, 1) - with self.app.test_request_context('/?page=string'): - self.assertEqual(parse_request(self.known_resource).page, 1) - - def test_parse_request_max_results(self): - default = config.PAGINATION_DEFAULT - limit = config.PAGINATION_LIMIT - with self.app.test_request_context(): - self.assertEqual(parse_request(self.known_resource).max_results, - default) - with self.app.test_request_context('/?max_results=%d' % (limit + 1)): - self.assertEqual(parse_request(self.known_resource).max_results, - limit) - with self.app.test_request_context('/?max_results=2'): - self.assertEqual(parse_request(self.known_resource).max_results, 2) - with self.app.test_request_context('/?max_results=-1'): - self.assertEqual(parse_request(self.known_resource).max_results, - default) - with self.app.test_request_context('/?max_results=0'): - self.assertEqual(parse_request(self.known_resource).max_results, - default) - with self.app.test_request_context('/?max_results=1.1'): - self.assertEqual(parse_request(self.known_resource).max_results, 1) - with self.app.test_request_context('/?max_results=string'): - self.assertEqual(parse_request(self.known_resource).max_results, - default) - - def test_parse_request_max_results_disabled_pagination(self): - self.app.config['DOMAIN'][self.known_resource]['pagination'] = False - default = 0 - limit = config.PAGINATION_LIMIT - with self.app.test_request_context(): - self.assertEqual(parse_request(self.known_resource).max_results, - default) - with self.app.test_request_context('/?max_results=%d' % (limit + 1)): - self.assertEqual(parse_request(self.known_resource).max_results, - limit + 1) - with self.app.test_request_context('/?max_results=2'): - self.assertEqual(parse_request(self.known_resource).max_results, 2) - with self.app.test_request_context('/?max_results=-1'): - self.assertEqual(parse_request(self.known_resource).max_results, - default) - with self.app.test_request_context('/?max_results=0'): - self.assertEqual(parse_request(self.known_resource).max_results, - default) - with self.app.test_request_context('/?max_results=1.1'): - self.assertEqual(parse_request(self.known_resource).max_results, 1) - with self.app.test_request_context('/?max_results=string'): - self.assertEqual(parse_request(self.known_resource).max_results, - default) - - def test_parse_request_if_modified_since(self): - ims = 'If-Modified-Since' - with self.app.test_request_context(): - self.assertEqual(parse_request( - self.known_resource).if_modified_since, None) - with self.app.test_request_context(headers=None): - self.assertEqual( - parse_request(self.known_resource).if_modified_since, None) - with self.app.test_request_context(headers={ims: self.datestr}): - self.assertEqual( - parse_request(self.known_resource).if_modified_since, - self.valid + timedelta(seconds=1)) - with self.app.test_request_context(headers={ims: 'not-a-date'}): - self.assertRaises(ValueError, parse_request, self.known_resource) - with self.app.test_request_context( - headers={ims: - self.datestr.replace('GMT', 'UTC')}): - self.assertRaises(ValueError, parse_request, self.known_resource) - self.assertRaises(ValueError, parse_request, self.known_resource) - - def test_parse_request_if_none_match(self): - with self.app.test_request_context(): - self.assertEqual(parse_request(self.known_resource).if_none_match, - None) - with self.app.test_request_context(headers=None): - self.assertEqual(parse_request(self.known_resource).if_none_match, - None) - with self.app.test_request_context(headers={'If-None-Match': - self.etag}): - self.assertEqual(parse_request(self.known_resource).if_none_match, - self.etag) - - def test_parse_request_if_match(self): - with self.app.test_request_context(): - self.assertEqual(parse_request(self.known_resource).if_match, None) - with self.app.test_request_context(headers=None): - self.assertEqual(parse_request(self.known_resource).if_match, None) - with self.app.test_request_context(headers={'If-Match': self.etag}): - self.assertEqual(parse_request(self.known_resource).if_match, - self.etag) - - def test_weak_date(self): - with self.app.test_request_context(): - self.app.config['DATE_FORMAT'] = '%Y-%m-%d' - self.assertEqual(weak_date(self.datestr), self.valid + - timedelta(seconds=1)) - - def test_str_to_date(self): - self.assertEqual(str_to_date(self.datestr), self.valid) - self.assertRaises(ValueError, str_to_date, 'not-a-date') - self.assertRaises(ValueError, str_to_date, - self.datestr.replace('GMT', 'UTC')) - - def test_date_to_str(self): - self.assertEqual(date_to_str(self.valid), self.datestr) - - def test_querydef(self): - self.assertEqual(querydef(max_results=10), '?max_results=10') - self.assertEqual(querydef(page=10), '?page=10') - self.assertEqual(querydef(where='wherepart'), '?where=wherepart') - self.assertEqual(querydef(sort='sortpart'), '?sort=sortpart') - - self.assertEqual(querydef(where='wherepart', sort='sortpart'), - '?where=wherepart&sort=sortpart') - self.assertEqual(querydef(max_results=10, sort='sortpart'), - '?max_results=10&sort=sortpart') - - def test_document_etag(self): - test = {'key1': 'value1', 'another': 'value2'} - challenge = dumps(test, sort_keys=True).encode('utf-8') - with self.app.test_request_context(): - self.assertEqual(hashlib.sha1(challenge).hexdigest(), - document_etag(test)) - - def test_document_etag_ignore_fields(self): - test = {'key1': 'value1', 'key2': 'value2'} - ignore_fields = ["key2"] - test_without_ignore = {'key1': 'value1'} - challenge = dumps(test_without_ignore, sort_keys=True).encode('utf-8') - with self.app.test_request_context(): - self.assertEqual(hashlib.sha1(challenge).hexdigest(), - document_etag(test, ignore_fields)) - - # not required fields can not be present - test = {'key1': 'value1', 'key2': 'value2'} - ignore_fields = ["key3"] - test_without_ignore = {'key1': 'value1', 'key2': 'value2'} - challenge = dumps(test_without_ignore, sort_keys=True).encode('utf-8') - with self.app.test_request_context(): - self.assertEqual(hashlib.sha1(challenge).hexdigest(), - document_etag(test, ignore_fields)) - - # ignore fiels nested using doting notation - test = {'key1': 'value1', 'dict': {'key2': 'value2', 'key3': 'value3'}} - ignore_fields = ['dict.key2'] - test_without_ignore = {'key1': 'value1', 'dict': {'key3': 'value3'}} - challenge = dumps(test_without_ignore, sort_keys=True).encode('utf-8') - with self.app.test_request_context(): - self.assertEqual(hashlib.sha1(challenge).hexdigest(), - document_etag(test, ignore_fields)) - - def test_extract_key_values(self): - test = { - 'key1': 'value1', - 'key2': { - 'key1': 'value2', - 'nested': { - 'key1': 'value3' - } - } - } - self.assertEqual(list(extract_key_values('key1', test)), - ['value1', 'value2', 'value3']) - - def test_debug_error_message(self): - with self.app.test_request_context(): - self.app.config['DEBUG'] = False - self.assertEqual(debug_error_message('An error message'), None) - self.app.config['DEBUG'] = True - self.assertEqual(debug_error_message('An error message'), - 'An error message') - - def test_validate_filters(self): - self.app.config['DOMAIN'][self.known_resource]['allowed_filters'] = [] - with self.app.test_request_context(): - self.assertTrue('key' in validate_filters( - {'key': 'val'}, - self.known_resource)) - self.assertTrue('key' in validate_filters( - {'key': ['val1', 'val2']}, - self.known_resource)) - self.assertTrue('key' in validate_filters( - {'key': {'$in': ['val1', 'val2']}}, - self.known_resource)) - self.assertTrue('key' in validate_filters( - {'$or': [{'key': 'val1'}, {'key': 'val2'}]}, - self.known_resource)) - self.assertTrue('$or' in validate_filters( - {'$or': 'val'}, - self.known_resource)) - self.assertTrue('$or' in validate_filters( - {'$or': {'key': 'val1'}}, - self.known_resource)) - self.assertTrue('$or' in validate_filters( - {'$or': ['val']}, - self.known_resource)) - - self.app.config['DOMAIN'][self.known_resource]['allowed_filters'] = \ - ['key'] - with self.app.test_request_context(): - self.assertTrue(validate_filters( - {'key': 'val'}, - self.known_resource) is None) - self.assertTrue(validate_filters( - {'key': ['val1', 'val2']}, - self.known_resource) is None) - self.assertTrue(validate_filters( - {'key': {'$in': ['val1', 'val2']}}, - self.known_resource) is None) - self.assertTrue(validate_filters( - {'$or': [{'key': 'val1'}, {'key': 'val2'}]}, - self.known_resource) is None) - - -class DummyEvent(object): - """ - Even handler that records the call parameters and asserts a check - - Usage:: - - app = Eve() - app.on_my_event = DummyEvent(element_not_deleted) - - In the test:: - - assert app.on_my_event.called[0] == expected_param_0 - """ - def __init__(self, check, deepcopy=False): - """ - :param check: method checking the state of something during the event. - :type: check: callable returning bool - :param deepcopy: Do we need to store a copy of the argument calls? In - some events arguments are changed after the event, so keeping a - reference to the original object doesn't allow a test to check what - was passed. The default is False. - :type deepcopy: bool - """ - self.__called = None - self.__check = check - self.__deepcopy = deepcopy - - def __call__(self, *args): - assert self.__check() - # In some method the arguments are changed after the events - if self.__deepcopy: - args = copy.deepcopy(args) - self.__called = args - - @property - def called(self): - """ - The results of the call to the event. - - :rtype: It returns None if the event hasn't been called or a tuple with - the positional arguments of the last call if called. - """ - return self.__called diff --git a/eve/utils.py b/eve/utils.py index 3065a7b4f..b0a0e249d 100644 --- a/eve/utils.py +++ b/eve/utils.py @@ -6,36 +6,40 @@ Utility functions and classes. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ -import sys -import eve import hashlib -import werkzeug.exceptions -from cerberus import Validator -from copy import copy -from flask import request -from flask import current_app as app +import sys +from copy import deepcopy from datetime import datetime, timedelta +from importlib import import_module + +import werkzeug.exceptions +from bson import UuidRepresentation from bson.json_util import dumps +from flask import current_app as app +from flask import request +from werkzeug.datastructures import MultiDict + +import eve from eve import RFC1123_DATE_FORMAT -from werkzeug import MultiDict -class Config(object): - """ Helper class used through the code to access configuration settings. +class Config(): + """Helper class used through the code to access configuration settings. If the main flaskapp object is not instantiated yet, returns the default setting in the eve __init__.py module, otherwise returns the flaskapp config value (which value might override the static defaults). """ + def __getattr__(self, name): try: # will return 'working outside of application context' if the # current_app is not available yet return app.config.get(name) - except: + except Exception: # fallback to the module-level default value return getattr(eve, name) @@ -45,18 +49,19 @@ def __getattr__(self, name): config = Config() -class ParsedRequest(object): - """ This class, by means of its attributes, describes a client request. +class ParsedRequest(): + """This class, by means of its attributes, describes a client request. - .. versuinchanged;; 9,5 + .. versionchanged:: 9,5 'args' keyword. - .. versonchanged:: 0.1.0 + .. versionchanged:: 0.1.0 'embedded' keyword. .. versionchanged:: 0.0.6 Projection queries ('?projection={"name": 1}') """ + # `where` value of the query string (?where). Defaults to None. where = None @@ -97,7 +102,7 @@ class ParsedRequest(object): def parse_request(resource): - """ Parses a client request, returning instance of :class:`ParsedRequest` + """Parses a client request, returning instance of :class:`ParsedRequest` containing relevant request data. :param resource: the resource currently being accessed by the client. @@ -109,7 +114,7 @@ def parse_request(resource): Support for custom query parameters via configuration settings. Minor DRY updates. - .. versionchagend:: 0.1.0 + .. versionchanged:: 0.1.0 Support for embedded documents. .. versionchanged:: 0.0.6 @@ -125,29 +130,27 @@ def parse_request(resource): r.args = args settings = config.DOMAIN[resource] - if settings['allowed_filters']: + if settings["allowed_filters"]: r.where = args.get(config.QUERY_WHERE) - if settings['projection']: + if settings["projection"]: r.projection = args.get(config.QUERY_PROJECTION) - if settings['sorting']: + if settings["sorting"]: r.sort = args.get(config.QUERY_SORT) - if settings['embedding']: + if settings["embedding"]: r.embedded = args.get(config.QUERY_EMBEDDED) - if settings['datasource']['aggregation']: + if settings["datasource"]["aggregation"]: r.aggregation = args.get(config.QUERY_AGGREGATION) r.show_deleted = config.SHOW_DELETED_PARAM in args - max_results_default = config.PAGINATION_DEFAULT if \ - settings['pagination'] else 0 + max_results_default = config.PAGINATION_DEFAULT if settings["pagination"] else 0 try: r.max_results = int(float(args[config.QUERY_MAX_RESULTS])) assert r.max_results > 0 - except (ValueError, werkzeug.exceptions.BadRequestKeyError, - AssertionError): + except (ValueError, werkzeug.exceptions.BadRequestKeyError, AssertionError): r.max_results = max_results_default - if settings['pagination']: + if settings["pagination"]: # TODO should probably return a 400 if 'page' is < 1 or non-numeric if config.QUERY_PAGE in args: try: @@ -157,43 +160,47 @@ def parse_request(resource): # TODO should probably return a 400 if 'max_results' < 1 or # non-numeric - if r.max_results > config.PAGINATION_LIMIT: - r.max_results = config.PAGINATION_LIMIT + # Fetch the custom pagination limit from the schema, default to the global one. + pagination_limit = settings.get("pagination_limit") or config.PAGINATION_LIMIT + if r.max_results > pagination_limit: + r.max_results = pagination_limit def etag_parse(challenge): if challenge in headers: etag = headers[challenge] # allow weak etags (Eve does not support byte-range requests) - if etag.startswith('W/\"'): - etag = etag.lstrip('W/') + if etag.startswith('W/"'): + etag = etag.lstrip("W/") # remove double quotes from challenge etag format to allow direct # string comparison with stored values - return etag.replace('\"', '') - else: - return None + return etag.replace('"', "") + return None if headers: - r.if_modified_since = weak_date(headers.get('If-Modified-Since')) - r.if_none_match = etag_parse('If-None-Match') - r.if_match = etag_parse('If-Match') + r.if_modified_since = weak_date(headers.get("If-Modified-Since")) + r.if_none_match = etag_parse("If-None-Match") + r.if_match = etag_parse("If-Match") return r def weak_date(date): - """ Returns a RFC-1123 string corresponding to a datetime value plus + """Returns a RFC-1123 string corresponding to a datetime value plus a 1 second timedelta. This is needed because when saved, documents LAST_UPDATED values have higher resolution than If-Modified-Since's, which is limited to seconds. :param date: the date to be adjusted. """ - return datetime.strptime(date, RFC1123_DATE_FORMAT) + \ - timedelta(seconds=1) if date else None + return ( + datetime.strptime(date, RFC1123_DATE_FORMAT) + timedelta(seconds=1) + if date + else None + ) def str_to_date(string): - """ Converts a date string formatted as defined in the configuration + """Converts a date string formatted as defined in the configuration to the corresponding datetime value. :param string: the RFC-1123 string to convert to datetime value. @@ -202,7 +209,7 @@ def str_to_date(string): def date_to_str(date): - """ Converts a datetime value to the format defined in the configuration file. + """Converts a datetime value to the format defined in the configuration file. :param date: the datetime value to convert. """ @@ -210,7 +217,7 @@ def date_to_str(date): def date_to_rfc1123(date): - """ Converts a datetime value to the corresponding RFC-1123 string. + """Converts a datetime value to the corresponding RFC-1123 string. :param date: the datetime value to convert. """ @@ -218,7 +225,7 @@ def date_to_rfc1123(date): def home_link(): - """ Returns a link to the API entry point/home page. + """Returns a link to the API entry point/home page. .. versionchanged:: 0.5 Link is relative to API root. @@ -226,11 +233,11 @@ def home_link(): .. versionchanged:: 0.0.3 Now returning a JSON link. """ - return {'title': 'home', 'href': '/'} + return {"title": "home", "href": "/"} def api_prefix(url_prefix=None, api_version=None): - """ Returns the prefix to API endpoints, according to the URL_PREFIX and + """Returns the prefix to API endpoints, according to the URL_PREFIX and API_VERSION configuration settings. :param url_prefix: the prefix string. If `None`, defaults to the current @@ -252,14 +259,20 @@ def api_prefix(url_prefix=None, api_version=None): if api_version is None: api_version = config.API_VERSION - prefix = '/%s' % url_prefix if url_prefix else '' - version = '/%s' % api_version if api_version else '' + prefix = "/%s" % url_prefix if url_prefix else "" + version = "/%s" % api_version if api_version else "" return prefix + version -def querydef(max_results=config.PAGINATION_DEFAULT, where=None, sort=None, - version=None, page=None, other_params=MultiDict()): - """ Returns a valid query string. +def querydef( + max_results=config.PAGINATION_DEFAULT, + where=None, + sort=None, + version=None, + page=None, + other_params=MultiDict(), +): + """Returns a valid query string. :param max_results: `max_result` part of the query string. Defaults to `PAGINATION_DEFAULT` @@ -274,29 +287,46 @@ def querydef(max_results=config.PAGINATION_DEFAULT, where=None, sort=None, Support for customizable query parameters. Add version to query string (#475). """ - where_part = '&%s=%s' % (config.QUERY_WHERE, where) if where else '' - sort_part = '&%s=%s' % (config.QUERY_SORT, sort) if sort else '' - page_part = '&%s=%s' % (config.QUERY_PAGE, page) if page and page > 1 \ - else '' - version_part = '&%s=%s' % (config.VERSION_PARAM, version) if version \ - else '' - max_results_part = '%s=%s' % (config.QUERY_MAX_RESULTS, max_results) \ - if max_results != config.PAGINATION_DEFAULT else '' - other_params_part = ''.join('&%s=%s' % (param, value) for param, values - in other_params.lists() for value in values) + where_part = "&%s=%s" % (config.QUERY_WHERE, where) if where else "" + sort_part = "&%s=%s" % (config.QUERY_SORT, sort) if sort else "" + page_part = "&%s=%s" % (config.QUERY_PAGE, page) if page and page > 1 else "" + version_part = "&%s=%s" % (config.VERSION_PARAM, version) if version else "" + max_results_part = ( + "%s=%s" % (config.QUERY_MAX_RESULTS, max_results) + if max_results != config.PAGINATION_DEFAULT + else "" + ) + other_params_part = "".join( + "&%s=%s" % (param, value) + for param, values in other_params.lists() + for value in values + ) # remove sort set by Eve if version is set if version and sort is not None: - sort_part = '&%s=%s' % (config.QUERY_SORT, sort) \ - if sort != '[("%s", 1)]' % config.VERSION else '' - - return ('?' + ''.join([max_results_part, where_part, sort_part, - version_part, page_part, other_params_part]) - .lstrip('&')).rstrip('?') + sort_part = ( + "&%s=%s" % (config.QUERY_SORT, sort) + if sort != '[("%s", 1)]' % config.VERSION + else "" + ) + + return ( + "?" + + "".join( + [ + max_results_part, + where_part, + sort_part, + version_part, + page_part, + other_params_part, + ] + ).lstrip("&") + ).rstrip("?") def document_etag(value, ignore_fields=None): - """ Computes and returns a valid ETag for the input value. + """Computes and returns a valid ETag for the input value. :param value: the value to compute the ETag with. :param ignore_fields: `ignore_fields` list of fields to skip to @@ -309,14 +339,28 @@ def document_etag(value, ignore_fields=None): Using bson.json_util.dumps over str(value) to make etag computation consistent between different runs and/or server instances (#16). """ + + def uuid_representation_as_string(): + uuid_map = { + "standard": UuidRepresentation.STANDARD, + "unspecified": UuidRepresentation.UNSPECIFIED, + "pythonLegacy": UuidRepresentation.PYTHON_LEGACY, + "csharpLegacy": UuidRepresentation.CSHARP_LEGACY, + "javaLegacy": UuidRepresentation.JAVA_LEGACY, + } + return uuid_map[ + config.MONGO_OPTIONS.get("uuidRepresentation", "standard") + ] + if ignore_fields: + def filter_ignore_fields(d, fields): # recursive function to remove the fields that they are in d, # field is a list of fields to skip or dotted fields to look up # to nested keys such as ["foo", "dict.bar", "dict.joe"] for field in fields: key, _, value = field.partition(".") - if value: + if value and key in d: filter_ignore_fields(d[key], [value]) elif field in d: d.pop(field) @@ -324,20 +368,30 @@ def filter_ignore_fields(d, fields): # not required fields can be not present pass - value_ = copy(value) + value_ = deepcopy(value) filter_ignore_fields(value_, ignore_fields) else: value_ = value h = hashlib.sha1() json_encoder = app.data.json_encoder_class() - h.update(dumps(value_, sort_keys=True, - default=json_encoder.default).encode('utf-8')) + from bson.json_util import DEFAULT_JSON_OPTIONS + + h.update( + dumps( + value_, + sort_keys=True, + default=json_encoder.default, + json_options=DEFAULT_JSON_OPTIONS.with_options( + uuid_representation=uuid_representation_as_string() + ), + ).encode("utf-8") + ) return h.hexdigest() def extract_key_values(key, d): - """ Extracts all values that match a key, even in nested dicts. + """Extracts all values that match a key, even in nested dicts. :param key: the lookup key. :param d: the dict to scan. @@ -347,13 +401,13 @@ def extract_key_values(key, d): if key in d: yield d[key] for k in d: - if isinstance(d[k], dict): + if isinstance(d, dict) and isinstance(d[k], dict): for j in extract_key_values(key, d[k]): yield j def debug_error_message(msg): - """ Returns the error message `msg` if config.DEBUG is True + """Returns the error message `msg` if config.DEBUG is True otherwise returns `None` which will cause Werkzeug to provide a generic error message @@ -361,13 +415,13 @@ def debug_error_message(msg): .. versionadded: 0.0.9 """ - if getattr(config, 'DEBUG', False): + if getattr(config, "DEBUG", False): return msg return None def validate_filters(where, resource): - """ Report any filter which is not allowed by `allowed_filters` + """Report any filter which is not allowed by `allowed_filters` :param where: the where clause, as a dict. :param resource: the resource being inspected. @@ -379,45 +433,97 @@ def validate_filters(where, resource): .. versionadded: 0.0.9 """ - operators = getattr(app.data, 'operators', set()) - allowed = config.DOMAIN[resource]['allowed_filters'] + list(operators) + operators = getattr(app.data, "operators", set()) + allowed = config.DOMAIN[resource]["allowed_filters"] + list(operators) def validate_filter(filter): for key, value in filter.items(): - if '*' not in allowed and key not in allowed: - return "filter on '%s' not allowed" % key + if "*" not in allowed: - if key in ('$or', '$and', '$nor'): + def recursive_check_allowed(filter_key, allowed_filters): + if filter_key not in allowed_filters: + base_composed_key, _, _ = filter_key.rpartition(".") + return base_composed_key and recursive_check_allowed( + base_composed_key, allowed_filters + ) + + return True + + if not recursive_check_allowed(key, allowed): + return "filter on '%s' not allowed" % key + + if key in ("$or", "$and", "$nor"): if not isinstance(value, list): return "operator '%s' expects a list of sub-queries" % key for v in value: if not isinstance(v, dict): - return "operator '%s' expects a list of sub-queries" \ - % key + return "operator '%s' expects a list of sub-queries" % key r = validate_filter(v) if r: return r else: if config.VALIDATE_FILTERS: - res_schema = config.DOMAIN[resource]['schema'] - if key not in res_schema: - return "filter on '%s' is invalid" - else: - field_schema = res_schema.get(key) - v = Validator({key: field_schema}) - if not v.validate({key: value}): - return "filter on '%s' is invalid" - else: + + def get_sub_schemas(base_schema): + def dict_sub_schema(base): + if base.get("type") == "dict": + return base.get("schema") + return None - if '*' in allowed and not config.VALIDATE_FILTERS: + if base_schema.get("type") == "list": + if "schema" in base_schema: + # Try to get dict sub-schema for arbitrary + # sized list + sub = dict_sub_schema(base_schema["schema"]) + return [sub] if sub is not None else [] + if "items" in base_schema: + # Try to get dict sub-schema(s) for + # fixed-size list + items = base_schema["items"] + sub_schemas = [] + for item in items: + sub = dict_sub_schema(item) + if sub is not None: + sub_schemas.append(sub) + + return sub_schemas + else: + sub = dict_sub_schema(base_schema) + return [sub] if sub is not None else [] + + def recursive_validate_filter(key, value, schema): + if key not in schema: + base_key, _, sub_keys = key.partition(".") + if sub_keys and base_key in schema: + # key is the composition of base field and + # sub-fields + sub_schemas = get_sub_schemas(schema[base_key]) + for sub_schema in sub_schemas: + if recursive_validate_filter( + sub_keys, value, sub_schema + ): + return True + + return False + field_schema = schema.get(key) + v = app.validator({key: field_schema}) + return v.validate({key: value}) + + res_schema = config.DOMAIN[resource]["schema"] + if not recursive_validate_filter(key, value, res_schema): + return "filter on '%s' is invalid" % key + + return None + + if "*" in allowed and not config.VALIDATE_FILTERS: return None return validate_filter(where) def auto_fields(resource): - """ Returns a list of automatically handled fields for a resource. + """Returns a list of automatically handled fields for a resource. :param resource: the resource currently being accessed by the client. @@ -429,21 +535,36 @@ def auto_fields(resource): resource_def = config.DOMAIN[resource] # preserved meta data - fields = [resource_def['id_field'], config.LAST_UPDATED, - config.DATE_CREATED, config.ETAG] + fields = [ + resource_def["id_field"], + config.LAST_UPDATED, + config.DATE_CREATED, + config.ETAG, + ] # on-the-fly meta data (not in data store) fields += [config.ISSUES, config.STATUS, config.LINKS] - if resource_def['versioning'] is True: + if resource_def["versioning"] is True: fields.append(config.VERSION) fields.append(config.LATEST_VERSION) # on-the-fly meta data - fields.append(resource_def['id_field'] + config.VERSION_ID_SUFFIX) + fields.append(resource_def["id_field"] + config.VERSION_ID_SUFFIX) - if resource_def['soft_delete'] is True: + if resource_def["soft_delete"] is True: fields.append(config.DELETED) return fields + # Base string type that is compatible with both Python 2.x and 3.x. str_type = str if sys.version_info[0] == 3 else basestring + + +def import_from_string(module_name): + """Imports module using string""" + try: + modules = module_name.split(".") + module_path, attr = ".".join(modules[:-1]), modules[-1] + return getattr(import_module(module_path), attr) + except (ImportError, AttributeError): + raise ImportError("Cannot import {}".format(module_name)) diff --git a/eve/validation.py b/eve/validation.py index 85a9b8f59..30bd8d5b2 100644 --- a/eve/validation.py +++ b/eve/validation.py @@ -8,9 +8,170 @@ datalayer-agnostic. Specialized Validator classes are implemented in the datalayer submodules. - :copyright: (c) 2016 by Nicola Iarocci. + :copyright: (c) 2017 by Nicola Iarocci. :license: BSD, see LICENSE for more details. """ -# flake8: noqa -from cerberus import ValidationError, SchemaError +import copy + +import cerberus +import cerberus.errors +from cerberus import DocumentError, SchemaError # noqa + +from eve.utils import config + + +class Validator(cerberus.Validator): + def __init__(self, *args, **kwargs): + if not config.VALIDATION_ERROR_AS_LIST: + kwargs["error_handler"] = SingleErrorAsStringErrorHandler + + self.is_update_operation = False + super().__init__(*args, **kwargs) + + def validate_update( + self, document, document_id, persisted_document=None, normalize_document=True + ): + """Validate method to be invoked when performing an update, not an + insert. + + :param document: the document to be validated. + :param document_id: the unique id of the document. + :param persisted_document: the persisted document to be updated. + :param normalize_document: whether apply normalization during patch. + """ + self.is_update_operation = True + self.document_id = document_id + self.persisted_document = persisted_document + return super().validate( + document, update=True, normalize=normalize_document + ) + + def validate_replace(self, document, document_id, persisted_document=None): + """Validation method to be invoked when performing a document + replacement. This differs from :func:`validation_update` since in this + case we want to perform a full :func:`validate` (the new document is to + be considered a new insertion and required fields needs validation). + However, like with validate_update, we also want the current document_id + not to be checked when validating 'unique' values. + + :param document: the document to be validated. + :param document_id: the unique id of the document. + :param persisted_document: the persisted document to be updated. + + .. versionadded:: 0.1.0 + """ + self.document_id = document_id + self.persisted_document = persisted_document + return super().validate(document) + + def _normalize_default(self, mapping, schema, field): + """{'nullable': True}""" + + # fields with no default are of no use here + if "default" not in schema[field]: + return + + # if the request already contains the field, we don't set any default + if field in mapping: + return + + # Field already set, we don't want to override with a default on an update + if self.is_update_operation and field in self.persisted_document: + return + + # If we reach here we are processing a field that has a default in the schema + # and the request doesn't explicitly set it. So we are in one of this cases: + # + # - An initial POST + # - A PATCH to an existing document where the field is not set + # - A PUT to a document where the field maybe is set + + super()._normalize_default(mapping, schema, field) + + def _normalize_default_setter(self, mapping, schema, field): + """{'oneof': [ + {'type': 'callable'}, + {'type': 'string'} + ]}""" + if not self.persisted_document or field not in self.persisted_document: + super()._normalize_default_setter(mapping, schema, field) + + def _validate_dependencies(self, dependencies, field, value): + """{'type': ['dict', 'hashable', 'list']}""" + persisted = self._filter_persisted_fields_not_in_document(dependencies) + if persisted: + dcopy = copy.copy(self.document) + for field in persisted: + dcopy[field] = self.persisted_document[field] + validator = self._get_child_validator() + validator.validate(dcopy, update=self.update) + self._error(validator._errors) + else: + super()._validate_dependencies(dependencies, field, value) + + def _filter_persisted_fields_not_in_document(self, fields): + def persisted_but_not_in_document(field): + return ( + field not in self.document + and self.persisted_document + and field in self.persisted_document + ) + + return [field for field in fields if persisted_but_not_in_document(field)] + + def _validate_readonly(self, read_only, field, value): + """{'type': 'boolean'}""" + persisted_value = ( + self.persisted_document.get(field) if self.persisted_document else None + ) + if value != persisted_value: + super()._validate_readonly(read_only, field, value) + + @property + def resource(self): + return self._config.get("resource", None) + + @resource.setter + def resource(self, value): + self._config["resource"] = value + + @property + def document_id(self): + return self._config.get("document_id", None) + + @document_id.setter + def document_id(self, value): + self._config["document_id"] = value + + @property + def persisted_document(self): + return self._config.get("persisted_document", None) + + @persisted_document.setter + def persisted_document(self, value): + self._config["persisted_document"] = value + + +class SingleErrorAsStringErrorHandler(cerberus.errors.BasicErrorHandler): + """Default Cerberus error handler for Eve. + + Since Cerberus 1.0, error messages for fields will always be returned as + lists, even in the case of a single error. To maintain compatibility with + clients, this error handler will unpack single-element error lists unless + the config item VALIDATION_ERROR_AS_LIST is True. + """ + + @property + def pretty_tree(self): + pretty = super().pretty_tree + self._unpack_single_element_lists(pretty) + return pretty + + def _unpack_single_element_lists(self, tree): + for field in tree: + error_list = tree[field] + if len(error_list) > 0 and isinstance(tree[field][-1], dict): + self._unpack_single_element_lists(tree[field][-1]) + if len(tree[field]) == 1: + tree[field] = tree[field][0] diff --git a/eve/versioning.py b/eve/versioning.py index a78e2749f..4506d2e76 100644 --- a/eve/versioning.py +++ b/eve/versioning.py @@ -1,34 +1,36 @@ -from flask import current_app as app, abort -from eve.utils import config, debug_error_message, ParsedRequest +from flask import abort +from flask import current_app as app from werkzeug.exceptions import BadRequestKeyError +from eve.utils import ParsedRequest, config, debug_error_message + def versioned_id_field(resource_settings): - """ Shorthand to add two commonly added versioning parameters. + """Shorthand to add two commonly added versioning parameters. .. versionadded: 0.4 """ - return resource_settings['id_field'] + app.config['VERSION_ID_SUFFIX'] + return resource_settings["id_field"] + app.config["VERSION_ID_SUFFIX"] def resolve_document_version(document, resource, method, latest_doc=None): - """ Version number logic for all methods. + """Version number logic for all methods. :param document: the document in question. :param resource: the resource of the request/document. - :param method: method coorsponding to the request. + :param method: method corresponding to the request. :param latest_doc: the most recent version of the document. .. versionadded:: 0.4 """ - resource_def = app.config['DOMAIN'][resource] - version = app.config['VERSION'] - latest_version = app.config['LATEST_VERSION'] + resource_def = app.config["DOMAIN"][resource] + version = app.config["VERSION"] + latest_version = app.config["LATEST_VERSION"] - if resource_def['versioning'] is True: - # especially on collection endpoints, we don't to encure an extra + if resource_def["versioning"] is True: + # especially on collection endpoints, we don't to ensure an extra # lookup if we are already pulling the latest version - if method == 'GET' and latest_doc is None: + if method == "GET" and latest_doc is None: if version not in document: # well it should be... the api designer must have turned on # versioning after data was already in the collection or the @@ -38,7 +40,7 @@ def resolve_document_version(document, resource, method, latest_doc=None): # include latest_doc if the request is for an older version so that we # can set the latest_version field in the response - if method == 'GET' and latest_doc is not None: + if method == "GET" and latest_doc is not None: if version not in latest_doc: # well it should be... the api designer must have turned on # versioning after data was already in the collection or the @@ -52,16 +54,20 @@ def resolve_document_version(document, resource, method, latest_doc=None): # was turned on or outside of Eve document[version] = 1 - if method == 'POST': + if method == "POST": # this one is easy! it is a new document document[version] = 1 - if method == 'PUT' or method == 'PATCH' or \ - (method == 'DELETE' and resource_def['soft_delete'] is True): + if ( + method == "PUT" + or method == "PATCH" + or (method == "DELETE" and resource_def["soft_delete"] is True) + ): if not latest_doc: - abort(500, description=debug_error_message( - 'I need the latest document here!' - )) + abort( + 500, + description=debug_error_message("I need the latest document here!"), + ) if version in latest_doc: # all is right in the world :) document[version] = latest_doc[version] + 1 @@ -74,19 +80,19 @@ def resolve_document_version(document, resource, method, latest_doc=None): def late_versioning_catch(document, resource): - """ Insert versioning copy of document for the previous version of a + """Insert versioning copy of document for the previous version of a document if it is missing. Intended for PUT and PATCH. :param resource: the resource of the request/document. - :param ids: a list of id number coorsponding to the documents parameter. + :param ids: a list of id number corresponding to the documents parameter. :param document: the documents be written by POST, PUT, or PATCH. .. versionadded:: 0.4 """ - resource_def = app.config['DOMAIN'][resource] - version = app.config['VERSION'] + resource_def = app.config["DOMAIN"][resource] + version = app.config["VERSION"] - if resource_def['versioning'] is True: + if resource_def["versioning"] is True: # TODO: Could directly check that there are no shadow copies for this # document. If there are shadow copies but the version field is in the # stored document, then something is wrong. (Modified outside of Eve?) @@ -95,31 +101,39 @@ def late_versioning_catch(document, resource): # The API maintainer must of turned on versioning after the # document was added to the database, so let's add this old version # to the shadow collection now as if it was a new document. - resolve_document_version(document, resource, 'POST') + resolve_document_version(document, resource, "POST") insert_versioning_documents(resource, document) def insert_versioning_documents(resource, documents): - """ Insert versioning copy of document. Intended for POST, PUT, and PATCH. + """Insert versioning copy of document. Intended for POST, PUT, and PATCH. :param resource: the resource of the request/document. :param documents: the documents be written by POST, PUT, or PATCH. .. versionadded:: 0.4 """ - resource_def = app.config['DOMAIN'][resource] - _id = resource_def['id_field'] + resource_def = app.config["DOMAIN"][resource] + _id = resource_def["id_field"] # push back versioned items if applicable # note: MongoDB doesn't have transactions! if the server dies, no # history will be saved. - if resource_def['versioning'] is True: + if resource_def["versioning"] is True: # force input as lists if not isinstance(documents, list): documents = [documents] + # if 'user-restricted resource access' is enabled and there's + # an Auth request active, inject the username into the document + request_auth_value = None + auth = resource_def["authentication"] + auth_field = resource_def["auth_field"] + if auth and auth_field: + request_auth_value = auth.get_request_auth_value() + # build vesioning documents - version = app.config['VERSION'] + version = app.config["VERSION"] versioned_documents = [] for index, document in enumerate(documents): ver_doc = {} @@ -134,17 +148,20 @@ def insert_versioning_documents(resource, documents): ver_doc[versioned_id_field(resource_def)] = document[_id] ver_doc[version] = document[version] + # push auth_field + if request_auth_value: + ver_doc[auth_field] = request_auth_value + # add document to the stack versioned_documents.append(ver_doc) # bulk insert - source = resource_def['datasource']['source'] - versionable_resource_name = source + app.config['VERSIONS'] + versionable_resource_name = resource + app.config["VERSIONS"] app.data.insert(versionable_resource_name, versioned_documents) def versioned_fields(resource_def): - """ Returns a list of versioned fields for a resource. + """Returns a list of versioned fields for a resource. :param resource_def: a resource definition. @@ -156,25 +173,26 @@ def versioned_fields(resource_def): .. versionadded:: 0.4 """ - if resource_def['versioning'] is not True: + if resource_def["versioning"] is not True: return [] - schema = resource_def['schema'] + schema = resource_def["schema"] - fields = [f for f in schema - if schema[f].get('versioned', True) is True and - f != resource_def['id_field']] + fields = [ + f + for f in schema + if schema[f].get("versioned", True) is True and f != resource_def["id_field"] + ] - fields.extend((app.config['LAST_UPDATED'], - app.config['ETAG'], - app.config['DELETED'], - )) + fields.extend( + (app.config["LAST_UPDATED"], app.config["ETAG"], app.config["DELETED"]) + ) return fields def diff_document(resource_def, old_doc, new_doc): - """ Returns a list of added or modified fields. + """Returns a list of added or modified fields. :param resource_def: a resource definition. :param old_doc: the document to compare against. @@ -183,25 +201,27 @@ def diff_document(resource_def, old_doc, new_doc): .. versionadded:: 0.4 """ diff = {} - fields = list(resource_def['schema'].keys()) + [ - app.config['VERSION'], - app.config['LATEST_VERSION'], - resource_def['id_field'], - app.config['LAST_UPDATED'], - app.config['DATE_CREATED'], - app.config['ETAG'], - app.config['LINKS']] - if resource_def['soft_delete'] is True: - fields.append(app.config['DELETED']) + fields = list(resource_def["schema"].keys()) + [ + app.config["VERSION"], + app.config["LATEST_VERSION"], + resource_def["id_field"], + app.config["LAST_UPDATED"], + app.config["DATE_CREATED"], + app.config["ETAG"], + app.config["LINKS"], + ] + if resource_def["soft_delete"] is True: + fields.append(app.config["DELETED"]) for field in fields: - if field in new_doc and \ - (field not in old_doc or new_doc[field] != old_doc[field]): + if field in new_doc and ( + field not in old_doc or new_doc[field] != old_doc[field] + ): diff[field] = new_doc[field] # This method does not show when fields are deleted. - for field in app.config['VERSION_DIFF_INCLUDE']: + for field in app.config["VERSION_DIFF_INCLUDE"]: if field in new_doc: diff[field] = new_doc[field] @@ -209,7 +229,7 @@ def diff_document(resource_def, old_doc, new_doc): def synthesize_versioned_document(document, delta, resource_def): - """ Synthesizes a versioned document from the latest document and the + """Synthesizes a versioned document from the latest document and the values of all versioned fields from the old version. This is accomplished by first creating a new document with only the un-versioned fields of latest document, before updating with versioned fields from the old @@ -229,11 +249,13 @@ def synthesize_versioned_document(document, delta, resource_def): id_field = versioned_id_field(resource_def) if id_field not in delta: - abort(400, description=debug_error_message( - 'You must include %s in any projection with a version query.' - % id_field - )) - delta[resource_def['id_field']] = delta[id_field] + abort( + 400, + description=debug_error_message( + "You must include %s in any projection with a version query." % id_field + ), + ) + delta[resource_def["id_field"]] = delta[id_field] del delta[id_field] # add unversioned fields from latest document to versioned_doc @@ -249,7 +271,7 @@ def synthesize_versioned_document(document, delta, resource_def): def get_old_document(resource, req, lookup, document, version): - """ Returns an old document if appropriate, otherwise returns a shallow + """Returns an old document if appropriate, otherwise returns a shallow copy of the given document. :param resource: the name of the resource. @@ -264,29 +286,30 @@ def get_old_document(resource, req, lookup, document, version): .. versionadded:: 0.4 """ - if version != 'all' and version != 'diffs' and version is not None: + if version != "all" and version != "diffs" and version is not None: try: version = int(version) assert version > 0 except (ValueError, BadRequestKeyError, AssertionError): - abort(400, description=debug_error_message( - 'Document version number should be an int greater than 0' - )) + abort( + 400, + description=debug_error_message( + "Document version number should be an int greater than 0" + ), + ) # parameters to find specific document version resource_def = config.DOMAIN[resource] if versioned_id_field(resource_def) not in lookup: - lookup[versioned_id_field(resource_def)] \ - = lookup[resource_def['id_field']] - del lookup[resource_def['id_field']] + lookup[versioned_id_field(resource_def)] = lookup[resource_def["id_field"]] + del lookup[resource_def["id_field"]] lookup[config.VERSION] = version # synthesize old document from latest and delta delta = app.data.find_one(resource + config.VERSIONS, req, **lookup) if not delta: abort(404) - old_document = synthesize_versioned_document( - document, delta, resource_def) + old_document = synthesize_versioned_document(document, delta, resource_def) else: # perform a shallow copy to allow this document to be used as a delta # for synthesize_versioned_document where id_field is removed @@ -296,7 +319,7 @@ def get_old_document(resource, req, lookup, document, version): def get_data_version_relation_document(data_relation, reference, latest=False): - """ Returns document at the version specified in data_relation, or at the + """Returns document at the version specified in data_relation, or at the latest version if passed `latest=True`. Returns None if data_relation cannot be satisfied. @@ -306,12 +329,12 @@ def get_data_version_relation_document(data_relation, reference, latest=False): .. versionadded:: 0.4 """ - value_field = data_relation['field'] - version_field = app.config['VERSION'] - collection = data_relation['resource'] + value_field = data_relation["field"] + version_field = app.config["VERSION"] + collection = data_relation["resource"] versioned_collection = collection + config.VERSIONS - resource_def = app.config['DOMAIN'][data_relation['resource']] - id_field = resource_def['id_field'] + resource_def = app.config["DOMAIN"][data_relation["resource"]] + id_field = resource_def["id_field"] # Fetch document data at the referenced version query = {version_field: reference[version_field]} @@ -322,10 +345,11 @@ def get_data_version_relation_document(data_relation, reference, latest=False): # The relation value field is unversioned, and will not be present in # the versioned collection. Need to find id field for version query req = ParsedRequest() - if resource_def['soft_delete']: + if resource_def["soft_delete"]: req.show_deleted = True latest_version = app.data.find_one( - collection, req, **{value_field: reference[value_field]}) + collection, req, **{value_field: reference[value_field]} + ) if not latest_version: return None query[versioned_id_field(resource_def)] = latest_version[id_field] @@ -348,7 +372,7 @@ def get_data_version_relation_document(data_relation, reference, latest=False): # Fetch the latest version of this document to use in version synthesis query = {id_field: referenced_version[versioned_id_field(resource_def)]} req = ParsedRequest() - if resource_def['soft_delete']: + if resource_def["soft_delete"]: # Still return latest after soft delete. It is needed to synthesize # full document version. req.show_deleted = True @@ -358,12 +382,13 @@ def get_data_version_relation_document(data_relation, reference, latest=False): # Syntheisze referenced version from latest and versioned data document = synthesize_versioned_document( - latest_version, referenced_version, resource_def) + latest_version, referenced_version, resource_def + ) return document def missing_version_field(data_relation, reference): - """ Returns a document if it matches the value_field but doesn't have a + """Returns a document if it matches the value_field but doesn't have a _version field. This is the scenario when there is data in the database before document versioning is turned on. @@ -372,11 +397,11 @@ def missing_version_field(data_relation, reference): .. versionadded:: 0.4 """ - value_field = data_relation['field'] - version_field = app.config['VERSION'] - collection = data_relation['resource'] + value_field = data_relation["field"] + version_field = app.config["VERSION"] + collection = data_relation["resource"] query = {} query[value_field] = reference[value_field] - query[version_field] = {'$exists': False} + query[version_field] = {"$exists": False} return app.data.find_one(collection, None, **query) diff --git a/examples/notifications.py b/examples/notifications.py index f34aa37f3..3c2e5204b 100644 --- a/examples/notifications.py +++ b/examples/notifications.py @@ -12,21 +12,22 @@ you want to inspect the `request` object you have to explicitly import it from flask. - Checkout Eve at https://github.com/nicolaiarocci/eve + Checkout Eve at https://github.com/pyeve/eve This snippet by Nicola Iarocci can be used freely for anything you like. Consider it public domain. """ from flask import request -from eve import Eve from notifications_settings import SETTINGS +from eve import Eve + app = Eve(auth=None, settings=SETTINGS) @app.before_request def before(): - print('the request object ready to be processed:', request) + print("the request object ready to be processed:", request) @app.after_request @@ -35,8 +36,9 @@ def after(response): Your function must take one parameter, a `response_class` object and return a new response object or the same (see Flask documentation). """ - print('and here we have the response object instead:', response) + print("and here we have the response object instead:", response) return response -if __name__ == '__main__': + +if __name__ == "__main__": app.run() diff --git a/examples/notifications_settings.py b/examples/notifications_settings.py index d559518d2..7d072e3b8 100644 --- a/examples/notifications_settings.py +++ b/examples/notifications_settings.py @@ -1,5 +1,2 @@ # -*- coding: utf-8 -*- -SETTINGS = { - 'DEBUG': True, - 'DOMAIN': {'test': {}} -} +SETTINGS = {"DEBUG": True, "DOMAIN": {"test": {}}} diff --git a/examples/security/bcrypt.py b/examples/security/bcrypt.py index 2722b1b27..a146e9519 100644 --- a/examples/security/bcrypt.py +++ b/examples/security/bcrypt.py @@ -14,27 +14,30 @@ You will need to install py-bcrypt: ``pip install py-bcrypt`` - Eve @ https://github.com/nicolaiarocci/eve + Eve @ https://github.com/pyeve/eve This snippet by Nicola Iarocci can be used freely for anything you like. Consider it public domain. """ import bcrypt +from settings_security import SETTINGS + from eve import Eve from eve.auth import BasicAuth -from settings_security import SETTINGS class BCryptAuth(BasicAuth): def check_auth(self, username, password, allowed_roles, resource, method): # use Eve's own db driver; no additional connections/resources are used - accounts = app.data.driver.db['accounts'] - account = accounts.find_one({'username': username}) - return account and \ - bcrypt.hashpw(password, account['password']) == account['password'] + accounts = app.data.driver.db["accounts"] + account = accounts.find_one({"username": username}) + return ( + account + and bcrypt.hashpw(password, account["password"]) == account["password"] + ) -if __name__ == '__main__': +if __name__ == "__main__": app = Eve(auth=BCryptAuth, settings=SETTINGS) app.run() diff --git a/examples/security/hmac.py b/examples/security/hmac.py index 9dd3f3cb6..dc06bc1bb 100644 --- a/examples/security/hmac.py +++ b/examples/security/hmac.py @@ -40,34 +40,36 @@ The HMACAuth class also supports access roles. - Checkout Eve at https://github.com/nicolaiarocci/eve + Checkout Eve at https://github.com/pyeve/eve This snippet by Nicola Iarocci can be used freely for anything you like. Consider it public domain. """ import hmac - -from eve import Eve -from eve.auth import HMACAuth from hashlib import sha1 from settings_security import SETTINGS +from eve import Eve +from eve.auth import HMACAuth + class HMACAuth(HMACAuth): - def check_auth(self, userid, hmac_hash, headers, data, allowed_roles, - resource, method): + def check_auth( + self, userid, hmac_hash, headers, data, allowed_roles, resource, method + ): # use Eve's own db driver; no additional connections/resources are used - accounts = app.data.driver.db['accounts'] - user = accounts.find_one({'userid': userid}) + accounts = app.data.driver.db["accounts"] + user = accounts.find_one({"userid": userid}) if user: - secret_key = user['secret_key'] + secret_key = user["secret_key"] # in this implementation we only hash request data, ignoring the # headers. - return user and \ - hmac.new(str(secret_key), str(data), sha1).hexdigest() == hmac_hash + return ( + user and hmac.new(str(secret_key), str(data), sha1).hexdigest() == hmac_hash + ) -if __name__ == '__main__': +if __name__ == "__main__": app = Eve(auth=HMACAuth, settings=SETTINGS) app.run() diff --git a/examples/security/roles.py b/examples/security/roles.py index 6213ecf42..83727714a 100644 --- a/examples/security/roles.py +++ b/examples/security/roles.py @@ -17,31 +17,31 @@ Since we are using werkzeug we don't need any extra import (werkzeug being one of Flask/Eve prerequisites). - Checkout Eve at https://github.com/nicolaiarocci/eve + Checkout Eve at https://github.com/pyeve/eve This snippet by Nicola Iarocci can be used freely for anything you like. Consider it public domain. """ -from eve import Eve -from eve.auth import BasicAuth +from settings_security import SETTINGS from werkzeug.security import check_password_hash -from settings_security import SETTINGS +from eve import Eve +from eve.auth import BasicAuth class RolesAuth(BasicAuth): def check_auth(self, username, password, allowed_roles, resource, method): # use Eve's own db driver; no additional connections/resources are used - accounts = app.data.driver.db['accounts'] - lookup = {'username': username} + accounts = app.data.driver.db["accounts"] + lookup = {"username": username} if allowed_roles: # only retrieve a user if his roles match ``allowed_roles`` - lookup['roles'] = {'$in': allowed_roles} + lookup["roles"] = {"$in": allowed_roles} account = accounts.find_one(lookup) - return account and check_password_hash(account['password'], password) + return account and check_password_hash(account["password"], password) -if __name__ == '__main__': +if __name__ == "__main__": app = Eve(auth=RolesAuth, settings=SETTINGS) app.run() diff --git a/examples/security/settings_security.py b/examples/security/settings_security.py index 5c08a6e58..f064db7c7 100644 --- a/examples/security/settings_security.py +++ b/examples/security/settings_security.py @@ -1,35 +1,17 @@ # -*- coding: utf-8 -*- SETTINGS = { - 'DEBUG': True, - 'MONGO_HOST': 'localhost', - 'MONGO_PORT': 27017, - 'MONGO_DBNAME': 'test_db', - 'DOMAIN': {'accounts': { - 'username': { - 'type': 'string', - 'minlength': 5, - 'maxlength': 20, - }, - 'password': { - 'type': 'string', - 'minlength': 5, - 'maxlength': 20, - }, - 'secret_key': { - 'type': 'string', - 'minlength': 5, - 'maxlength': 20, - }, - 'roles': { - 'type': 'string', - 'minlength': 10, - 'maxlength': 50, - }, - 'token': { - 'type': 'string', - 'minlength': 10, - 'maxlength': 50, - }, - }} + "DEBUG": True, + "MONGO_HOST": "localhost", + "MONGO_PORT": 27017, + "MONGO_DBNAME": "test_db", + "DOMAIN": { + "accounts": { + "username": {"type": "string", "minlength": 5, "maxlength": 20}, + "password": {"type": "string", "minlength": 5, "maxlength": 20}, + "secret_key": {"type": "string", "minlength": 5, "maxlength": 20}, + "roles": {"type": "string", "minlength": 10, "maxlength": 50}, + "token": {"type": "string", "minlength": 10, "maxlength": 50}, + } + }, } diff --git a/examples/security/sha1-hmac.py b/examples/security/sha1-hmac.py index 615147a67..b72f232e8 100644 --- a/examples/security/sha1-hmac.py +++ b/examples/security/sha1-hmac.py @@ -15,28 +15,27 @@ Since we are using werkzeug we don't need any extra import (werkzeug being one of Flask/Eve prerequisites). - Checkout Eve at https://github.com/nicolaiarocci/eve + Checkout Eve at https://github.com/pyeve/eve This snippet by Nicola Iarocci can be used freely for anything you like. Consider it public domain. """ -from eve import Eve -from eve.auth import BasicAuth +from settings_security import SETTINGS from werkzeug.security import check_password_hash -from settings_security import SETTINGS +from eve import Eve +from eve.auth import BasicAuth class Sha1Auth(BasicAuth): def check_auth(self, username, password, allowed_roles, resource, method): # use Eve's own db driver; no additional connections/resources are used - accounts = app.data.driver.db['accounts'] - account = accounts.find_one({'username': username}) - return account and \ - check_password_hash(account['password'], password) + accounts = app.data.driver.db["accounts"] + account = accounts.find_one({"username": username}) + return account and check_password_hash(account["password"], password) -if __name__ == '__main__': +if __name__ == "__main__": app = Eve(auth=Sha1Auth, settings=SETTINGS) app.run() diff --git a/examples/security/token.py b/examples/security/token.py index e43b086d6..9c03291c6 100644 --- a/examples/security/token.py +++ b/examples/security/token.py @@ -14,30 +14,30 @@ made explicitly public (by fiddling with some settings you can open one or more resources and/or methods to public access -see docs). - Checkout Eve at https://github.com/nicolaiarocci/eve + Checkout Eve at https://github.com/pyeve/eve This snippet by Nicola Iarocci can be used freely for anything you like. Consider it public domain. """ +from settings_security import SETTINGS + from eve import Eve from eve.auth import TokenAuth -from settings_security import SETTINGS - class TokenAuth(TokenAuth): def check_auth(self, token, allowed_roles, resource, method): """For the purpose of this example the implementation is as simple as possible. A 'real' token should probably contain a hash of the - username/password combo, which sould then validated against the account - data stored on the DB. + username/password combo, which should be then validated against the + account data stored on the DB. """ # use Eve's own db driver; no additional connections/resources are used - accounts = app.data.driver.db['accounts'] - return accounts.find_one({'token': token}) + accounts = app.data.driver.db["accounts"] + return accounts.find_one({"token": token}) -if __name__ == '__main__': +if __name__ == "__main__": app = Eve(auth=TokenAuth, settings=SETTINGS) app.run() diff --git a/py26-requirements.txt b/py26-requirements.txt deleted file mode 100644 index 4f81f46e5..000000000 --- a/py26-requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ --r requirements.txt -ordereddict diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..5ffb71086 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.black] +safe = true +quiet = true diff --git a/pytest.ini b/pytest.ini index 04aeb8c88..60a49bc66 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,8 @@ [pytest] +testpaths=eve/tests python_files=eve/tests/*.py addopts = --maxfail=2 -rf --capture=no norecursedirs = testsuite .tox +filterwarnings = + ignore :: DeprecationWarning + ignore :: PendingDeprecationWarning diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index a11ca110c..000000000 --- a/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -Cerberus==0.9.2 -Events==0.2.1 -Flask-PyMongo==0.4.1 -Flask==0.10.1 -itsdangerous==0.24 -Jinja2==2.7.3 -MarkupSafe==0.23 -pymongo==3.2.1 -simplejson==3.8.2 -Werkzeug==0.11.4 diff --git a/response.md b/response.md new file mode 100644 index 000000000..aabae5b09 --- /dev/null +++ b/response.md @@ -0,0 +1,111 @@ +# Security Report Review — Eve Framework + +Thank you for taking the time to analyze the Eve codebase and submit these six security reports. We take security seriously and have carefully reviewed each finding against the current codebase. + +After thorough analysis, we found that **one report (#4) identified a genuine code defect**, which we have already fixed. The remaining five reports describe attack vectors that are either already mitigated by Eve's default configuration or based on an incorrect understanding of the code flow. + +Below is our detailed response to each report, followed by a summary. + +--- + +## Report #1 — MongoDB Query Injection via Unsanitized 'where' Parameter (CVSS 8.7) + +**Assessment: Mostly mitigated; documentation improved.** + +Two of the three PoC examples are already mitigated by Eve's default configuration: + +- `{"$where": "sleep(5000)"}` — **blocked**. `$where` is in `MONGO_QUERY_BLACKLIST` by default since Eve 0.7.x (and the recursive traversal bypass was fixed in 0.7.10). +- `{"email": {"$regex": ".*"}}` — **blocked**. `$regex` is also in the default blacklist. + +The third PoC (`{"password": {"$gt": ""}}`) does work with default settings, as `$gt` is a legitimate MongoDB comparison operator required for normal API operation (e.g., date range queries). + +**Regarding `auth_field` bypass:** the report states that injected conditions can bypass `auth_field`-protected resources. This is inaccurate — the `auth_field` filter is applied server-side as an additional AND condition on the query. A client-supplied `where` clause cannot override or remove it; documents belonging to other users are not returned. + +**Regarding the actual risk:** with the default configuration (`ALLOWED_FILTERS = ['*']`), comparison operators like `$gt`/`$lt`/`$ne` can be used for blind enumeration of field values on any filterable field. This is a known trade-off of Eve's design as a flexible REST framework — it intentionally exposes MongoDB's query language to API consumers. Eve provides the configuration tools to mitigate this (`ALLOWED_FILTERS`, `VALIDATE_FILTERS`, `MONGO_QUERY_BLACKLIST`), and we have now strengthened the documentation with explicit security warnings about this. + +We do not consider this a code vulnerability warranting a CVE, as the framework behaves as designed and provides adequate configuration options for hardening. + +--- + +## Report #2 — MongoDB Operator Injection via $where JavaScript Execution (CVSS 9.3) + +**Assessment: Not a vulnerability.** + +While `$where` is listed in the `Mongo.operators` set (the set of *recognized* MongoDB operators), it is also included in `MONGO_QUERY_BLACKLIST`, which defaults to `['$where', '$regex']`. The `_sanitize()` method in `eve/io/mongo/mongo.py` checks incoming queries against this blacklist and aborts with a 400 error if any blacklisted operator is found. This check is applied recursively to nested query structures. + +All PoCs in this report — both the DoS via busy-loop and the blind extraction via boolean/timing channels — are rejected with a 400 response under default settings. The `$where` operator would only be executable if an administrator explicitly removes it from `MONGO_QUERY_BLACKLIST`, which is a deliberate opt-in. + +The CVSS 9.3 rating is not applicable as the attack surface does not exist under default configuration. + +--- + +## Report #3 — MongoDB ReDoS via Uncontrolled $regex Operator (CVSS 8.7) + +**Assessment: Not a vulnerability.** + +This report follows the same pattern as #2. While `$regex` is listed in the `Mongo.operators` set, it is **also included in `MONGO_QUERY_BLACKLIST`**, which defaults to `['$where', '$regex']`. + +All PoCs — including the catastrophic backtracking patterns — are rejected with a 400 response under default settings. The `_sanitize()` method blocks `$regex` before the query ever reaches MongoDB. The operator would only be usable if an administrator explicitly removes it from the blacklist. + +The CVSS 8.7 rating is not applicable as the attack surface does not exist under default configuration. + +--- + +## Report #4 — JSONP Callback Injection via Unvalidated User Input (CVSS 7.1) + +**Assessment: Valid finding. Fixed.** + +When JSONP support was explicitly enabled via `JSONP_ARGUMENT`, the callback parameter was interpolated into the response without validation, allowing arbitrary JavaScript injection. + +We have addressed this with two changes: + +1. **Fix:** The JSONP callback is now validated against a strict pattern (`^[a-zA-Z_$][\w$.]*$`) ensuring only valid JavaScript identifiers are accepted. Invalid callback names are rejected with a 400 response. + +2. **Deprecation:** `JSONP_ARGUMENT` is now deprecated and will be removed in a future release. JSONP is a legacy technology superseded by CORS, which Eve already supports. A `DeprecationWarning` is emitted at startup when the setting is configured. + +We note that the CVSS 7.1 rating overstates the practical impact: JSONP is disabled by default (`JSONP_ARGUMENT = None`) and requires explicit opt-in. + +--- + +## Report #5 — IDOR via Auth Field Bypass on PUT with Upsert (CVSS 5.9) + +**Assessment: Not a vulnerability.** + +The report claims that during the PUT upsert path, an attacker can supply an arbitrary `auth_field` value in the request body to create documents attributed to another user. This is incorrect. + +When `UPSERT_ON_PUT` triggers `post_internal()`, the function calls `resolve_user_restricted_access()` (in `eve/methods/common.py`), which **unconditionally overwrites** the `auth_field` with the authenticated user's identity: + +```python +document[auth_field] = request_auth_value +``` + +This is not a conditional assignment — any attacker-supplied value is replaced with the real authenticated user's identity before the document is persisted. The PoC would result in a document owned by `user_b` (the actual authenticated user), not `user_a` as claimed. + +--- + +## Report #6 — GridFS Arbitrary File Retrieval via ObjectId Manipulation (CVSS 7.1) + +**Assessment: Not a vulnerability as described.** + +The specific attack path described — accessing files across resources via resource endpoints — is inaccurate. When media fields are embedded in documents (the default behavior, `RETURN_MEDIA_AS_URL = False`), file content is served as part of the document response and goes through the normal document retrieval pipeline, including `auth_field` enforcement and all access control checks. + +We acknowledge a tangential concern: when `RETURN_MEDIA_AS_URL` is set to `True` (not the default), the global `/media/` endpoint serves files from GridFS based solely on the ObjectId, with only generic authentication and no ownership check. This is a known design choice — the ObjectId acts as an opaque capability token, and access control is enforced at the document level. ObjectIds must be known to be exploited and are only revealed through authorized document access. + +The PoC conflates resource endpoints with the media endpoint and does not demonstrate the claimed attack. + +--- + +## Summary + +| # | Report | CVSS | Assessment | Action Taken | +|---|--------|------|------------|--------------| +| 1 | MongoDB Query Injection via `where` | 8.7 | Mostly mitigated by default | Improved documentation | +| 2 | `$where` JavaScript Execution | 9.3 | Blocked by default blacklist | None required | +| 3 | `$regex` ReDoS | 8.7 | Blocked by default blacklist | None required | +| 4 | JSONP Callback Injection | 7.1 | **Valid** (opt-in feature) | Fixed + deprecated JSONP | +| 5 | IDOR via Auth Field on PUT Upsert | 5.9 | Incorrect — auth_field is overwritten | None required | +| 6 | GridFS IDOR via ObjectId | 7.1 | Inaccurate attack path | None required | + +Reports #2 and #3 appear to have been produced by analyzing the `Mongo.operators` set in isolation, without tracing the full query pipeline through the `_sanitize()` method and `MONGO_QUERY_BLACKLIST`. We encourage future analysis to follow the complete code path from request to database execution. + +We appreciate the effort in examining Eve's security posture and welcome further reports that identify actual code defects. diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 2a9acf13d..000000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[bdist_wheel] -universal = 1 diff --git a/setup.py b/setup.py index 85053479c..627f94e89 100755 --- a/setup.py +++ b/setup.py @@ -1,58 +1,68 @@ #!/usr/bin/env python +import io +import re +from collections import OrderedDict -from setuptools import setup, find_packages -DESCRIPTION = ("Python REST API for Humans.") -with open('README.rst') as f: +from setuptools import find_packages, setup + +DESCRIPTION = "Python REST API for Humans." +with open("README.rst") as f: LONG_DESCRIPTION = f.read() -install_requires = [ - 'cerberus>=0.9.2,<0.10', - 'events>=0.2.1,<0.3', - 'simplejson>=3.3.0,<4.0', - 'werkzeug>=0.9.4,<0.11.4', - 'markupsafe>=0.23,<1.0', - 'jinja2>=2.7.2,<3.0', - 'itsdangerous>=0.22,<1.0', - 'flask>=0.10.1,<0.11', - 'pymongo>=3.2', - 'flask-pymongo>=0.4', -] +with io.open("eve/__init__.py", "rt", encoding="utf8") as f: + VERSION = re.search(r"__version__ = \"(.*?)\"", f.read()).group(1) -try: - from collections import OrderedDict # noqa -except ImportError: - # Python 2.6 needs this back-port - install_requires.append('ordereddict') +INSTALL_REQUIRES = [ + "cerberus>=1.1,<2.0", + "events>=0.3,<0.4", + "flask", + "pymongo", + "simplejson>=3.3.0,<4.0", +] +EXTRAS_REQUIRE = { + "docs": ["sphinx", "alabaster", "doc8"], + "tests": ["redis", "testfixtures", "pytest", "tox"], +} +EXTRAS_REQUIRE["dev"] = EXTRAS_REQUIRE["tests"] + EXTRAS_REQUIRE["docs"] setup( - name='Eve', - version='0.6.5.dev0', + name="Eve", + version=VERSION, description=DESCRIPTION, long_description=LONG_DESCRIPTION, - author='Nicola Iarocci', - author_email='eve@nicolaiarocci.com', - url='http://python-eve.org', - license='BSD', + long_description_content_type="text/x-rst", + author="Nicola Iarocci", + author_email="eve@nicolaiarocci.com", + url="http://python-eve.org", + project_urls=OrderedDict( + ( + ("Documentation", "http://python-eve.org"), + ("Code", "https://github.com/pyeve/eve"), + ("Issue tracker", "https://github.com/pyeve/eve/issues"), + ) + ), + license="BSD", platforms=["any"], - packages=find_packages(), - test_suite="eve.tests", - install_requires=install_requires, - tests_require=['redis', 'testfixtures'], + packages=find_packages(exclude=["tests*"]), + test_suite="tests", + install_requires=INSTALL_REQUIRES, + extras_require=EXTRAS_REQUIRE, + python_requires=">=3.7", classifiers=[ - 'Development Status :: 4 - Beta', - 'Environment :: Web Environment', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", + "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Topic :: Software Development :: Libraries :: Python Modules", ], ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..b86067ff7 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,646 @@ +# -*- coding: utf-8 -*- + +import os +import random +import string +import unittest +from datetime import datetime, timedelta, timezone + +import simplejson as json +from bson import ObjectId +from pymongo import MongoClient + +import eve +from eve import ETAG, ISSUES +from eve.methods.common import field_definition +from .test_settings import ( + DOMAIN, + MONGO_DBNAME, + MONGO_HOST, + MONGO_PASSWORD, + MONGO_PORT, + MONGO_USERNAME, +) +from eve.utils import date_to_str + +try: + from urlparse import parse_qs, urlparse +except ImportError: + from urllib.parse import parse_qs, urlparse + + +class ValueStack: + """ + Descriptor to store multiple assignments in an attribute. + + Due to the multiple self.app = assignments in tests, it is difficult to + keep track by hand of the applications created in order to close their + database connections. This descriptor helps with it. + """ + + def __init__(self, on_delete): + """ + :param on_delete: Action to execute when the attribute is deleted + """ + self.elements = [] + self.on_delete = on_delete + + def __set__(self, obj, val): + self.elements.append(val) + + def __get__(self, obj, objtype): + return self.elements[-1] if self.elements else None + + def __delete__(self, obj): + for item in self.elements: + self.on_delete(item) + self.elements = [] + + +def close_pymongo_connection(app): + """ + Close the pymongo connection in an eve/flask app + """ + if "pymongo" not in app.extensions: + return + del app.extensions["pymongo"] + del app.media + + +def setup_add_url_rule(app, original_add_url_rule): + def wrapped_add_url_rule(*args, **kwargs): + original_got_first_request = app._got_first_request + app._got_first_request = False + original_add_url_rule(*args, **kwargs) + app._got_first_request = original_got_first_request + + return wrapped_add_url_rule + + +class TestMinimal(unittest.TestCase): + """Start the building of the tests for an application + based on Eve by subclassing this class and provide proper settings + using :func:`setUp()` + """ + + app = ValueStack(close_pymongo_connection) + + def setUp(self, settings_file=None, url_converters=None): + """Prepare the test fixture + + :param settings_file: the name of the settings file. Defaults + to `eve/tests/test_settings.py`. + """ + self.this_directory = os.path.dirname(os.path.realpath(__file__)) + if settings_file is None: + # Load the settings file, using a robust path + settings_file = os.path.join(self.this_directory, "test_settings.py") + + self.connection = None + self.known_resource_count = 101 + self.setupDB() + + self.settings_file = settings_file + self.app = eve.Eve(settings=self.settings_file, url_converters=url_converters) + + self.test_client = self.app.test_client() + + self.domain = self.app.config["DOMAIN"] + + self.clearSchemaCache() + + def tearDown(self): + del self.app + self.dropDB() + + def assert200(self, status): + self.assertEqual(status, 200) + + def assert201(self, status): + self.assertEqual(status, 201) + + def assert204(self, status): + self.assertEqual(status, 204) + + def assert301(self, status): + self.assertEqual(status, 301) + + def assert304(self, status): + self.assertEqual(status, 304) + + def assert404(self, status): + self.assertEqual(status, 404) + + def assert422(self, status): + self.assertEqual(status, 422) + + def get(self, resource, query="", item=None): + if resource in self.domain: + resource = self.domain[resource]["url"] + if item: + request = "/%s/%s%s" % (resource, item, query) + else: + request = "/%s%s" % (resource, query) + + r = self.test_client.get(request) + return self.parse_response(r) + + def post(self, url, data, headers=None, content_type="application/json"): + if headers is None: + headers = [] + headers.append(("Content-Type", content_type)) + r = self.test_client.post(url, data=json.dumps(data), headers=headers) + return self.parse_response(r) + + def put(self, url, data, headers=None): + if headers is None: + headers = [] + headers.append(("Content-Type", "application/json")) + r = self.test_client.put(url, data=json.dumps(data), headers=headers) + return self.parse_response(r) + + def patch(self, url, data, headers=None): + if headers is None: + headers = [] + headers.append(("Content-Type", "application/json")) + r = self.test_client.patch(url, data=json.dumps(data), headers=headers) + return self.parse_response(r) + + def delete(self, url, headers=None): + r = self.test_client.delete(url, headers=headers) + return self.parse_response(r) + + def parse_response(self, r): + try: + v = json.loads(r.get_data()) + except json.JSONDecodeError: + v = None + return v, r.status_code + + def assertValidationErrorStatus(self, status): + self.assertEqual(status, self.app.config.get("VALIDATION_ERROR_STATUS")) + + def assertValidationError(self, response, matches): + self.assertTrue(eve.STATUS in response) + self.assertTrue(eve.STATUS_ERR in response[eve.STATUS]) + self.assertTrue(ISSUES in response) + issues = response[ISSUES] + self.assertTrue(len(issues)) + + for k, v in matches.items(): + self.assertTrue(k in issues) + self.assertTrue(v in issues[k]) + + def assertExpires(self, resource): + # TODO if we ever get access to response.date (it is None), compare + # it with Expires + r = self.test_client.get(resource) + + expires = r.headers.get("Expires") + self.assertTrue(expires is not None) + + def assertCacheControl(self, resource): + r = self.test_client.get(resource) + + cache_control = r.headers.get("Cache-Control") + self.assertTrue(cache_control is not None) + self.assertEqual( + cache_control, self.domain[self.known_resource]["cache_control"] + ) + + def assertIfModifiedSince(self, resource): + r = self.test_client.get(resource) + + last_modified = r.headers.get("Last-Modified") + self.assertTrue(last_modified is not None) + r = self.test_client.get( + resource, headers=[("If-Modified-Since", last_modified)] + ) + self.assert304(r.status_code) + self.assertTrue(not r.get_data()) + + def assertItem(self, item, resource): + self.assertEqual(type(item), dict) + + updated_on = item.get(self.app.config["LAST_UPDATED"]) + self.assertTrue(updated_on is not None) + try: + datetime.strptime(updated_on, self.app.config["DATE_FORMAT"]) + except Exception as e: + self.fail( + 'Cannot convert field "%s" to datetime: %s' + % (self.app.config["LAST_UPDATED"], e) + ) + + created_on = item.get(self.app.config["DATE_CREATED"]) + self.assertTrue(updated_on is not None) + try: + datetime.strptime(created_on, self.app.config["DATE_FORMAT"]) + except Exception as e: + self.fail( + 'Cannot convert field "%s" to datetime: %s' + % (self.app.config["DATE_CREATED"], e) + ) + + link = item.get("_links") + _id = item.get(self.domain[resource]["id_field"]) + self.assertItemLink(link, _id) + + def assertPagination(self, response, page, total, max_results): + p_key, mr_key = ( + self.app.config["QUERY_PAGE"], + self.app.config["QUERY_MAX_RESULTS"], + ) + self.assertTrue(self.app.config["META"] in response) + meta = response.get(self.app.config["META"]) + self.assertTrue(p_key in meta) + self.assertTrue(mr_key in meta) + self.assertTrue("total" in meta) + self.assertEqual(meta[p_key], page) + self.assertEqual(meta[mr_key], max_results) + self.assertEqual(meta["total"], total) + + def assertHomeLink(self, links): + self.assertTrue("parent" in links) + link = links["parent"] + self.assertTrue("title" in link) + self.assertTrue("href" in link) + self.assertEqual("home", link["title"]) + self.assertEqual("/", link["href"]) + + def assertResourceLink(self, links, resource): + self.assertTrue("self" in links) + link = links["self"] + self.assertTrue("title" in link) + self.assertTrue("href" in link) + url = self.domain[resource]["url"] + self.assertEqual(url, link["title"]) + self.assertEqual("%s" % url, link["href"]) + + def assertCollectionLink(self, links, resource): + self.assertTrue("collection" in links) + link = links["collection"] + self.assertTrue("title" in link) + self.assertTrue("href" in link) + url = self.domain[resource]["url"] + self.assertEqual(url, link["title"]) + self.assertEqual("%s" % url, link["href"]) + + def assertNextLink(self, links, page): + self.assertTrue("next" in links) + link = links["next"] + self.assertTrue("title" in link) + self.assertTrue("href" in link) + self.assertEqual("next page", link["title"]) + self.assertTrue("%s=%d" % (self.app.config["QUERY_PAGE"], page) in link["href"]) + + def assertPrevLink(self, links, page): + self.assertTrue("prev" in links) + link = links["prev"] + self.assertTrue("title" in link) + self.assertTrue("href" in link) + self.assertEqual("previous page", link["title"]) + if page > 1: + self.assertTrue( + "%s=%d" % (self.app.config["QUERY_PAGE"], page) in link["href"] + ) + + def assertItemLink(self, links, item_id): + self.assertTrue("self" in links) + link = links["self"] + # TODO we are too deep here to get a hold of the due title. Should fix. + self.assertTrue("title" in link) + self.assertTrue("href" in link) + self.assertTrue("/%s" % item_id in link["href"]) + + def assertLastLink(self, links, page): + if page: + self.assertTrue("last" in links) + link = links["last"] + self.assertTrue("title" in link) + self.assertTrue("href" in link) + self.assertEqual("last page", link["title"]) + self.assertTrue( + "%s=%d" % (self.app.config["QUERY_PAGE"], page) in link["href"] + ) + else: + self.assertTrue("last" not in links) + + def assertRelatedLink(self, links, field): + self.assertTrue("related" in links) + data_relation_links = links["related"] + self.assertTrue(field in data_relation_links) + related_field_links = data_relation_links[field] + for related_field_link in ( + related_field_links + if isinstance(related_field_links, list) + else [related_field_links] + ): + self.assertTrue("title" in related_field_link) + self.assertTrue("href" in related_field_link) + + def assertCustomParams(self, link, params): + self.assertTrue("href" in link) + url_params = parse_qs(urlparse(link["href"]).query) + for param, values in params.lists(): + self.assertTrue(param in url_params) + for value in values: + self.assertTrue(value in url_params[param]) + + def assert400(self, status): + self.assertEqual(status, 400) + + def assert401(self, status): + self.assertEqual(status, 401) + + def assert401or405(self, status): + self.assertTrue(status in [401, 405]) + + def assert403(self, status): + self.assertEqual(status, 403) + + def assert405(self, status): + self.assertEqual(status, 405) + + def assert412(self, status): + self.assertEqual(status, 412) + + def assert428(self, status): + self.assertEqual(status, 428) + + def assert429(self, status): + self.assertEqual(status, 429) + + def assert500(self, status): + self.assertEqual(status, 500) + + def setupDB(self): + self.connection = MongoClient( + MONGO_HOST, MONGO_PORT, uuidRepresentation="standard" + ) + self.connection.drop_database(MONGO_DBNAME) + if MONGO_USERNAME: + db = self.connection[MONGO_DBNAME] + info = db.command("usersInfo", MONGO_USERNAME) + if any(user["user"] == MONGO_USERNAME for user in info["users"]): + db.command("dropUser", MONGO_USERNAME) + db.command( + "createUser", MONGO_USERNAME, pwd=MONGO_PASSWORD, roles=["dbAdmin"] + ) + self.bulk_insert() + + def bulk_insert(self): + pass + + def dropDB(self): + self.connection = MongoClient(MONGO_HOST, MONGO_PORT) + self.connection.drop_database(MONGO_DBNAME) + self.connection.close() + + def clearSchemaCache(self): + field_definition.cache_clear() + + +class TestBase(TestMinimal): + def setUp(self, url_converters=None): + super().setUp(url_converters=url_converters) + + self.disabled_bulk = "disabled_bulk" + self.disabled_bulk_url = "/%s" % self.domain[self.disabled_bulk]["url"] + + self.known_resource = "contacts" + self.known_resource_url = "/%s" % self.domain[self.known_resource]["url"] + self.empty_resource = "empty" + self.empty_resource_url = "/%s" % self.empty_resource + + self.unknown_resource = "unknown" + self.unknown_resource_url = "/%s" % self.unknown_resource + self.unknown_item_id = "4f46445fc88e201858000000" + self.unknown_item_name = "unknown" + + self.unknown_item_id_url = "/%s/%s" % ( + self.domain[self.known_resource]["url"], + self.unknown_item_id, + ) + self.unknown_item_name_url = "/%s/%s" % ( + self.domain[self.known_resource]["url"], + self.unknown_item_name, + ) + + self.readonly_resource = "payments" + self.readonly_resource_url = "/%s" % self.domain[self.readonly_resource]["url"] + + self.different_resource = "users" + self.different_resource_url = ( + "/%s" % self.domain[self.different_resource]["url"] + ) + + self.different_resource_exclude = "contacts_hide_born" + self.different_resource_exclude_url = ( + "/%s" % self.domain[self.different_resource_exclude]["url"] + ) + + self.resource_exclude_media = "contacts_hide_media" + self.resource_exclude_media_url = ( + "/%s" % self.domain[self.resource_exclude_media]["url"] + ) + + response, _ = self.get("contacts", "?max_results=2") + contact = self.response_item(response) + self.item = contact + self.item_id = contact[self.domain["contacts"]["id_field"]] + self.item_name = contact["ref"] + self.item_tid = contact["tid"] + self.item_etag = contact[ETAG] + self.item_ref = contact["ref"] + self.item_rows = contact["rows"] + self.item_id_url = "/%s/%s" % ( + self.domain[self.known_resource]["url"], + self.item_id, + ) + self.item_name_url = "/%s/%s" % ( + self.domain[self.known_resource]["url"], + self.item_name, + ) + self.alt_ref = self.response_item(response, 1)["ref"] + + response, _ = self.get("payments", "?max_results=1") + self.readonly_id = self.response_item(response)["_id"] + self.readonly_id_url = "%s/%s" % (self.readonly_resource_url, self.readonly_id) + + response, _ = self.get("users") + user = self.response_item(response) + self.user_id = user[self.domain["users"]["id_field"]] + self.user_username = user["username"] + self.user_name = user["ref"] + self.user_etag = user[ETAG] + self.user_id_url = "/%s/%s" % ( + self.domain[self.different_resource]["url"], + self.user_id, + ) + self.user_username_url = "/%s/%s" % ( + self.domain[self.different_resource]["url"], + self.user_username, + ) + + response, _ = self.get("invoices") + invoice = self.response_item(response) + self.invoice_id = invoice[self.domain["invoices"]["id_field"]] + self.invoice_etag = invoice[ETAG] + self.invoice_id_url = "/%s/%s" % ( + self.domain["invoices"]["url"], + self.invoice_id, + ) + + self.epoch = date_to_str(datetime(1970, 1, 1)) + + self.products = "products" + self.products_url = "/%s" % self.domain[self.products]["url"] + + self.child_products = "child_products" + self.child_products_url = "/%s" % self.domain[self.child_products]["url"] + + self.test_patch = "test_patch" + self.test_patch_url = "/%s" % self.domain[self.test_patch]["url"] + + self.app.add_url_rule = setup_add_url_rule(self.app, self.app.add_url_rule) + + def response_item(self, response, i=0): + if self.app.config["HATEOAS"]: + return response["_items"][i] + return response[i] + + def random_contacts(self, num, standard_date_fields=True): + schema = DOMAIN["contacts"]["schema"] + contacts = [] + for i in range(num): + dt = datetime.utcnow().replace(microsecond=0, tzinfo=timezone.utc) + contact = { + "ref": self.random_string(schema["ref"]["maxlength"]), + "prog": i, + "role": random.choice(schema["role"]["allowed"]), + "title": schema["title"]["default"], + "rows": self.random_rows(random.randint(1, 5)), + "alist": self.random_list(random.randint(0, 5)), + "location": { + "address": "address " + self.random_string(5), + "city": "city " + self.random_string(3), + }, + "born": datetime.today() + timedelta(days=random.randint(-10, 10)), + "tid": ObjectId(), + "read_only_field": schema["read_only_field"]["default"], + "dependency_field1": schema["dependency_field1"]["default"], + # The schema for contacts has a field named 'unsetted_default_value_field' + # That is not initialized here on purpose. See put test on + # tests.put.put_default_value_when_field_missing + } + if standard_date_fields: + contact[eve.LAST_UPDATED] = dt + contact[eve.DATE_CREATED] = dt + + contacts.append(contact) + return contacts + + def to_list_string(self, list_of_strings): + return '["%s"]' % '","'.join(list_of_strings) + + def random_users(self, num): + users = self.random_contacts(num) + for user in users: + user["username"] = self.random_string(10) + return users + + def random_payments(self, num): + payments = [] + for i in range(num): + dt = datetime.utcnow().replace(microsecond=0, tzinfo=timezone.utc) + payment = { + "a_string": self.random_string(10), + "a_number": i, + eve.LAST_UPDATED: dt, + eve.DATE_CREATED: dt, + } + payments.append(payment) + return payments + + def random_invoices(self, num): + invoices = [] + for _ in range(num): + dt = datetime.utcnow().replace(microsecond=0, tzinfo=timezone.utc) + invoice = { + "inv_number": self.random_string(10), + eve.LAST_UPDATED: dt, + eve.DATE_CREATED: dt, + } + invoices.append(invoice) + return invoices + + def random_products(self, num): + schema = DOMAIN["products"]["schema"] + products = [] + for _ in range(num): + products.append( + { + "sku": self.random_string(schema["sku"]["maxlength"]), + "title": ( + "Hypercube " + + self.random_string(2) + + str(random.randint(100, 1000)) + ), + } + ) + return products + + def random_string(self, num): + return "".join(random.choice(string.ascii_uppercase) for x in range(num)) + + def random_hexstring(self, num): + return "".join(random.choice(string.hexdigits).lower() for x in range(num)) + + def random_list(self, num): + alist = [] + for i in range(num): + alist.append(["string" + str(i), random.randint(1000, 9999)]) + return alist + + def random_rows(self, num): + schema = DOMAIN["contacts"]["schema"]["rows"]["schema"]["schema"] + rows = [] + for _ in range(num): + rows.append( + { + "sku": self.random_hexstring(schema["sku"]["maxlength"]), + "price": random.randint(100, 1000), + } + ) + return rows + + def random_internal_transactions(self, num): + transactions = [] + for i in range(num): + dt = datetime.utcnow().replace(microsecond=0, tzinfo=timezone.utc) + transaction = { + "internal_string": self.random_string(10), + "internal_number": i, + eve.LAST_UPDATED: dt, + eve.DATE_CREATED: dt, + } + transactions.append(transaction) + return transactions + + def generate_products(self): + products = self.random_products(10) + skus = [product["sku"] for product in products] + for counter, sku in enumerate(skus[5:], 0): + products[counter]["parent_product"] = sku + return products + + def bulk_insert(self): + _db = self.connection[MONGO_DBNAME] + _db.contacts.insert_many(self.random_contacts(self.known_resource_count)) + _db.contacts.insert_many(self.random_users(2)) + _db.payments.insert_many(self.random_payments(10)) + _db.invoices.insert_many(self.random_invoices(1)) + _db.internal_transactions.insert_many(self.random_internal_transactions(4)) + products = self.generate_products() + _db.products.insert_many(products) diff --git a/tests/auth.py b/tests/auth.py new file mode 100644 index 000000000..a86c7887d --- /dev/null +++ b/tests/auth.py @@ -0,0 +1,930 @@ +# -*- coding: utf-8 -*- +from io import BytesIO + +import simplejson as json +from bson import ObjectId + +import eve +from eve import Eve +from eve.auth import BasicAuth, HMACAuth, TokenAuth + +from . import TestBase +from .test_settings import MONGO_DBNAME + + +class ValidBasicAuth(BasicAuth): + def __init__(self): + self.request_auth_value = "admin" + super().__init__() + + def check_auth(self, username, password, allowed_roles, resource, method): + self.set_request_auth_value(self.request_auth_value) + return ( + username in ("admin", "alt") + and password == "secret" + and ("admin" in allowed_roles if allowed_roles else True) + ) + + +class BadBasicAuth(BasicAuth): + pass + + +class ValidTokenAuth(TokenAuth): + def check_auth(self, token, allowed_roles, resource, method): + return token == "test_token" and ( + "admin" in allowed_roles if allowed_roles else True + ) + + +class BadTokenAuth(TokenAuth): + pass + + +class ValidHMACAuth(HMACAuth): + def check_auth( + self, userid, hmac_hash, headers, data, allowed_roles, resource, method + ): + self.set_request_auth_value(userid) + return ( + userid == "admin" + and hmac_hash == "secret" + and ("admin" in allowed_roles if allowed_roles else True) + ) + + +class BadHMACAuth(HMACAuth): + pass + + +class TestBasicAuth(TestBase): + def setUp(self): + super().setUp() + self.app = Eve(settings=self.settings_file, auth=ValidBasicAuth) + self.test_client = self.app.test_client() + self.content_type = ("Content-Type", "application/json") + self.valid_auth = [ + ("Authorization", "Basic YWRtaW46c2VjcmV0"), + self.content_type, + ] + self.invalid_auth = [ + ("Authorization", "Basic YWRtaW46c2VjcmV1"), + self.content_type, + ] + self.valid_media_auth = [ + ("Authorization", "Basic YWRtaW46c2VjcmV0"), + ("Content-Type", "multipart/form-data"), + ] + self.setUpRoles() + self.app.set_defaults() + + def setUpRoles(self): + for _, schema in self.app.config["DOMAIN"].items(): + schema["allowed_roles"] = ["admin"] + schema["allowed_read_roles"] = ["reader"] + schema["allowed_item_roles"] = ["admin"] + schema["allowed_item_read_roles"] = ["reader"] + schema["allowed_item_write_roles"] = ["editor"] + + def test_custom_auth(self): + self.assertTrue(isinstance(self.app.auth, ValidBasicAuth)) + + def test_restricted_home_access(self): + r = self.test_client.get("/") + self.assert401(r.status_code) + + def test_restricted_resource_access(self): + r = self.test_client.get(self.known_resource_url) + self.assert401(r.status_code) + r = self.test_client.post(self.known_resource_url) + self.assert401(r.status_code) + r = self.test_client.delete(self.known_resource_url) + self.assert401(r.status_code) + + def test_restricted_item_access(self): + r = self.test_client.get(self.item_id_url) + self.assert401(r.status_code) + r = self.test_client.patch(self.item_id_url) + self.assert401(r.status_code) + r = self.test_client.delete(self.item_id_url) + self.assert401(r.status_code) + + def test_authorized_home_access(self): + r = self.test_client.get("/", headers=self.valid_auth) + self.assert200(r.status_code) + + def test_authorized_resource_access(self): + r = self.test_client.get(self.known_resource_url, headers=self.valid_auth) + self.assert200(r.status_code) + r = self.test_client.post( + self.known_resource_url, + data=json.dumps({"k": "value"}), + headers=self.valid_auth, + ) + self.assertValidationErrorStatus(r.status_code) + r = self.test_client.delete(self.known_resource_url, headers=self.valid_auth) + self.assert204(r.status_code) + + def test_authorized_item_access(self): + r = self.test_client.get(self.item_id_url, headers=self.valid_auth) + self.assert200(r.status_code) + r = self.test_client.patch( + self.item_id_url, data=json.dumps({"k": "value"}), headers=self.valid_auth + ) + self.assert428(r.status_code) + r = self.test_client.delete(self.item_id_url, headers=self.valid_auth) + self.assert428(r.status_code) + + def test_authorized_media_access(self): + self.app.config["RETURN_MEDIA_AS_BASE64_STRING"] = False + self.app.config["RETURN_MEDIA_AS_URL"] = True + self.app.config["BANDWIDTH_SAVER"] = False + self.app._init_media_endpoint() + + clean = b"my new file contents" + test_field, test_value = "ref", "9234567890123456789054321" + data = {"media": (BytesIO(clean), "test.txt"), test_field: test_value} + r, s = self.parse_response( + self.test_client.post( + self.known_resource_url, data=data, headers=self.valid_media_auth + ) + ) + self.assert201(s) + + file_url = r["media"] + r = self.test_client.get(file_url, headers=self.invalid_auth) + self.assert401(r.status_code) + r = self.test_client.get(file_url, headers=self.valid_auth) + self.assert200(r.status_code) + + def test_authorized_schema_access(self): + self.app.config["SCHEMA_ENDPOINT"] = "schema" + self.app._init_schema_endpoint() + + r = self.test_client.get( + "/schema/%s" % self.known_resource, headers=self.valid_auth + ) + self.assert200(r.status_code) + + def test_unauthorized_home_access(self): + r = self.test_client.get("/", headers=self.invalid_auth) + self.assert401(r.status_code) + + def test_unauthorized_resource_access(self): + r = self.test_client.get(self.known_resource_url, headers=self.invalid_auth) + self.assert401(r.status_code) + r = self.test_client.post(self.known_resource_url, headers=self.invalid_auth) + self.assert401(r.status_code) + r = self.test_client.delete(self.known_resource_url, headers=self.invalid_auth) + self.assert401(r.status_code) + + def test_unauthorized_item_access(self): + r = self.test_client.get(self.item_id_url, headers=self.invalid_auth) + self.assert401(r.status_code) + r = self.test_client.patch(self.item_id_url, headers=self.invalid_auth) + self.assert401(r.status_code) + r = self.test_client.delete(self.item_id_url, headers=self.invalid_auth) + self.assert401(r.status_code) + + def test_unauthorized_schema_access(self): + self.app.config["SCHEMA_ENDPOINT"] = "schema" + self.app._init_schema_endpoint() + + r = self.test_client.get( + "/schema/%s" % self.known_resource, headers=self.invalid_auth + ) + self.assert401(r.status_code) + + def test_home_public_methods(self): + self.app.config["PUBLIC_METHODS"] = ["GET"] + r = self.test_client.get("/") + self.assert200(r.status_code) + self.test_restricted_resource_access() + self.test_restricted_item_access() + + def test_public_methods_resource(self): + self.app.config["PUBLIC_METHODS"] = ["GET"] + domain = self.app.config["DOMAIN"] + for resource, settings in domain.items(): + del settings["public_methods"] + self.app.set_defaults() + del domain["peopleinvoices"] + del domain["peoplerequiredinvoices"] + del domain["peoplesearches"] + del domain["internal_transactions"] + del domain["child_products"] + for resource in domain: + url = self.app.config["URLS"][resource] + r = self.test_client.get(url) + self.assert200(r.status_code) + r = self.test_client.post(url, data={"key1": "value1"}) + self.assert401or405(r.status_code) + r = self.test_client.delete(url) + self.assert401or405(r.status_code) + self.test_restricted_item_access() + + def test_public_methods_but_locked_resource(self): + self.app.config["PUBLIC_METHODS"] = ["GET"] + domain = self.app.config["DOMAIN"] + for _, settings in domain.items(): + del settings["public_methods"] + self.app.set_defaults() + domain[self.known_resource]["public_methods"] = [] + r = self.test_client.get(self.known_resource_url) + self.assert401(r.status_code) + + def test_public_methods_but_locked_item(self): + self.app.config["PUBLIC_ITEM_METHODS"] = ["GET"] + domain = self.app.config["DOMAIN"] + for _, settings in domain.items(): + del settings["public_item_methods"] + self.app.set_defaults() + domain[self.known_resource]["public_item_methods"] = [] + r = self.test_client.get(self.item_id_url) + self.assert401(r.status_code) + + def test_public_methods_item(self): + self.app.config["PUBLIC_ITEM_METHODS"] = ["GET"] + for _, settings in self.app.config["DOMAIN"].items(): + del settings["public_item_methods"] + self.app.set_defaults() + # we're happy with testing just one client endpoint, but for sake of + # completeness we shold probably test item endpoints for every resource + r = self.test_client.get(self.item_id_url) + self.assert200(r.status_code) + r = self.test_client.patch(self.item_id_url) + self.assert401(r.status_code) + r = self.test_client.delete(self.item_id_url) + self.assert401(r.status_code) + + def test_bad_auth_class(self): + self.app = Eve(settings=self.settings_file, auth=BadBasicAuth) + self.test_client = self.app.test_client() + r = self.test_client.get("/", headers=self.valid_auth) + # will fail because check_auth() is not implemented in the custom class + self.assert500(r.status_code) + + def test_instanced_auth(self): + # tests that the 'auth' argument can also be a class instance. See + # #248. + + # current self.app instance has an instanced auth class already, and it + # is consistent with the super class running the test (Token, HMAC or + # Basic), so we are just going to use it (self.app.auth) on a new Eve + # instance. + + auth = self.app.auth + self.app = Eve(settings=self.settings_file, auth=auth) + self.test_client = self.app.test_client() + r = self.test_client.get("/", headers=self.valid_auth) + self.assert200(r.status_code) + + def test_rfc2617_response(self): + r = self.test_client.get("/") + self.assert401(r.status_code) + self.assertTrue( + ("WWW-Authenticate", 'Basic realm="%s"' % eve.__package__) + in r.headers.to_wsgi_list() + ) + + def test_allowed_roles_does_not_change(self): + self.test_client.get(self.known_resource_url) + resource = self.app.config["DOMAIN"][self.known_resource] + self.assertEqual(resource["allowed_roles"], ["admin"]) + + def test_allowed_item_roles_does_not_change(self): + self.test_client.get(self.item_id_url) + resource = self.app.config["DOMAIN"][self.known_resource] + self.assertEqual(resource["allowed_item_roles"], ["admin"]) + + def test_ALLOWED_ROLES_does_not_change(self): + self.app.config["ALLOWED_ROLES"] = ["admin"] + self.app.config["ALLOWED_READ_ROLES"] = ["reader"] + self.test_client.get("/") + self.assertEqual(self.app.config["ALLOWED_ROLES"], ["admin"]) + + +class TestTokenAuth(TestBasicAuth): + def setUp(self): + super().setUp() + self.app = Eve(settings=self.settings_file, auth=ValidTokenAuth) + self.test_client = self.app.test_client() + self.valid_auth = [ + ("Authorization", "Basic dGVzdF90b2tlbjo="), + self.content_type, + ] + self.valid_media_auth = [ + ("Authorization", "Basic dGVzdF90b2tlbjo="), + ("Content-Type", "multipart/form-data"), + ] + self.setUpRoles() + + def test_custom_auth(self): + self.assertTrue(isinstance(self.app.auth, ValidTokenAuth)) + + +class TestBearerTokenAuth(TestTokenAuth): + def setUp(self): + super().setUp() + self.valid_auth = [("Authorization", "Token test_token"), self.content_type] + self.valid_media_auth = [ + ("Authorization", "Token test_token"), + ("Content-Type", "multipart/form-data"), + ] + + def test_bad_auth_class(self): + self.app = Eve(settings=self.settings_file, auth=BadTokenAuth) + self.test_client = self.app.test_client() + r = self.test_client.get("/", headers=self.valid_auth) + # will fail because check_auth() is not implemented in the custom class + self.assert500(r.status_code) + + +class TestCustomTokenAuth(TestTokenAuth): + def setUp(self): + super().setUp() + self.valid_auth = [("Authorization", "Token test_token"), self.content_type] + self.valid_media_auth = [ + ("Authorization", "Token test_token"), + ("Content-Type", "multipart/form-data"), + ] + + def test_bad_auth_class(self): + self.app = Eve(settings=self.settings_file, auth=BadTokenAuth) + self.test_client = self.app.test_client() + r = self.test_client.get("/", headers=self.valid_auth) + # will fail because check_auth() is not implemented in the custom class + self.assert500(r.status_code) + + +class TestHMACAuth(TestBasicAuth): + def setUp(self): + super().setUp() + self.app = Eve(settings=self.settings_file, auth=ValidHMACAuth) + self.test_client = self.app.test_client() + self.valid_auth = [("Authorization", "admin:secret"), self.content_type] + self.valid_media_auth = [ + ("Authorization", "admin:secret"), + ("Content-Type", "multipart/form-data"), + ] + self.setUpRoles() + + def test_custom_auth(self): + self.assertTrue(isinstance(self.app.auth, ValidHMACAuth)) + + def test_bad_auth_class(self): + self.app = Eve(settings=self.settings_file, auth=BadHMACAuth) + self.test_client = self.app.test_client() + r = self.test_client.get("/", headers=self.valid_auth) + # will fail because check_auth() is not implemented in the custom class + self.assert500(r.status_code) + + def test_rfc2617_response(self): + r = self.test_client.get("/") + self.assert401(r.status_code) + + def test_post_resource_hmac_auth(self): + # Test that user restricted access works with HMAC auth. + resource_def = self.app.config["DOMAIN"]["restricted"] + resource_def["auth_field"] = "username" + url = resource_def["url"] + data = {"ref": "0123456789123456789012345"} + + r = self.app.test_client().post( + url, + data=json.dumps(data), + headers=self.valid_auth, + content_type="application/json", + ) + + # Verify that we can retrieve the same document + r, status = self.parse_response( + self.app.test_client().get(url, headers=self.valid_auth) + ) + self.assert200(status) + self.assertEqual(len(r["_items"]), 1) + self.assertEqual(r["_items"][0]["ref"], data["ref"]) + + +class TestResourceAuth(TestBase): + def test_resource_only_auth(self): + # no auth at the API level + self.app = Eve(settings=self.settings_file) + self.test_client = self.app.test_client() + # explicit auth for just one resource + self.app.config["DOMAIN"]["contacts"]["authentication"] = ValidBasicAuth() + self.app.config["DOMAIN"]["empty"]["authentication"] = ValidTokenAuth() + self.app.set_defaults() + basic_auth = [("Authorization", "Basic YWRtaW46c2VjcmV0")] + token_auth = [("Authorization", "Basic dGVzdF90b2tlbjo=")] + + # 'contacts' endpoints are protected + r = self.test_client.get(self.known_resource_url) + self.assert401(r.status_code) + r = self.test_client.get(self.item_id_url) + self.assert401(r.status_code) + # both with BasicAuth. + _, status = self.parse_response( + self.test_client.get(self.known_resource_url, headers=basic_auth) + ) + self.assert200(status) + _, status = self.parse_response( + self.test_client.get(self.item_id_url, headers=basic_auth) + ) + self.assert200(status) + + # 'empty' resource endpoint is also protected + r = self.test_client.get(self.empty_resource_url) + self.assert401(r.status_code) + # but with TokenAuth + r = self.test_client.get(self.empty_resource_url, headers=token_auth) + self.assert200(r.status_code) + + # other resources are not protected + r = self.test_client.get(self.readonly_resource_url) + self.assert200(r.status_code) + + +class TestUserRestrictedAccess(TestBase): + def setUp(self): + super().setUp() + + self.app = Eve(settings=self.settings_file, auth=ValidBasicAuth) + + # using this endpoint since it is a copy of 'contacts' with + # no filter on the datasource + self.url = "restricted" + self.resource = self.app.config["DOMAIN"][self.url] + self.test_client = self.app.test_client() + + self.valid_auth = [("Authorization", "Basic YWRtaW46c2VjcmV0")] + self.invalid_auth = [("Authorization", "Basic IDontThinkSo")] + self.field_name = "auth_field" + self.data = json.dumps({"ref": "0123456789123456789012345"}) + + for _, settings in self.app.config["DOMAIN"].items(): + settings[self.field_name] = "username" + + self.resource["public_methods"] = [] + + def test_get(self): + data, status = self.parse_response( + self.test_client.get(self.url, headers=self.valid_auth) + ) + self.assert200(status) + # no data has been saved by user 'admin' yet, + # so assert we get an empty result set back. + self.assertEqual(len(data["_items"]), 0) + + # Add a user belonging to `admin` + new_user = self.random_contacts(1)[0] + new_user["username"] = "admin" + _db = self.connection[self.app.config["MONGO_DBNAME"]] + _db.contacts.insert_one(new_user) + + # Verify that we can retrieve it + data2, status2 = self.parse_response( + self.test_client.get(self.url, headers=self.valid_auth) + ) + self.assert200(status2) + self.assertEqual(len(data2["_items"]), 1) + + def test_get_by_auth_field_criteria(self): + """If we attempt to retrieve an object by the same field + that is in `auth_field`, then the request is /unauthorized/, + and should fail and return 401. + + This test verifies that the `auth_field` does not overwrite + a `client_filter` or url param. + """ + _, status = self.parse_response( + self.test_client.get(self.user_username_url, headers=self.valid_auth) + ) + self.assert401(status) + + def test_get_by_auth_field_id(self): + """To test handling of ObjectIds""" + # set auth_field to `_id` + self.domain["users"][self.field_name] = self.domain["users"]["id_field"] + + _, status = self.parse_response( + self.test_client.get(self.user_id_url, headers=self.valid_auth) + ) + self.assert401(status) + + def test_filter_by_auth_field_id(self): + """To test handling of ObjectIds when using a `where` clause + We need to make sure we *match* an object ID when it is the + same + """ + _id = ObjectId("deadbeefdeadbeefdeadbeef") + resource_def = self.app.config["DOMAIN"]["users"] + resource_def["authentication"].request_auth_value = _id + + # set auth_field to `_id` + resource_def[self.field_name] = "_id" + + # Retrieving a /different user/ by id returns 401 + user_url = "/users/" + filter_by_id = 'where=_id==ObjectId("%s")' + filter_query = filter_by_id % self.user_id + + _, status = self.parse_response( + self.test_client.get( + "%s?%s" % (user_url, filter_query), headers=self.valid_auth + ) + ) + self.assert401(status) + + # Create a user account belonging to admin + new_user = self.random_contacts(1)[0] + new_user["_id"] = _id + new_user["username"] = "admin" + _db = self.connection[self.app.config["MONGO_DBNAME"]] + _db.contacts.insert_one(new_user) + + # Retrieving /the same/ user by id returns OK + filter_query_2 = filter_by_id % "deadbeefdeadbeefdeadbeef" + data2, status2 = self.parse_response( + self.test_client.get( + "%s?%s" % (user_url, filter_query_2), headers=self.valid_auth + ) + ) + self.assert200(status2) + self.assertEqual(len(data2["_items"]), 1) + + def test_collection_get_public(self): + """Test that if GET is in `public_methods` the `auth_field` + criteria is overruled + """ + self.resource["public_methods"].append("GET") + data, status = self.parse_response(self.test_client.get(self.url)) # no auth + self.assert200(status) + # no data has been saved by user 'admin' yet, + # but we should get all the other results back + self.assertEqual(len(data["_items"]), 25) + + def test_item_get_public(self): + """Test that if GET is in `public_item_methods` the `auth_field` + criteria is overruled + """ + self.resource["public_item_methods"].append("GET") + data, status = self.parse_response( + self.test_client.get(self.item_id_url, headers=self.valid_auth) + ) + self.assert200(status) + self.assertEqual(data["_id"], self.item_id) + + def test_post(self): + _, status = self.post() + self.assert201(status) + data, status = self.parse_response( + self.test_client.get(self.url, headers=self.valid_auth) + ) + self.assert200(status) + # len of 1 as there are is only 1 doc saved by user + + def test_unique_to_user_on_post(self): + # make the field unique to user, not globally. + self.resource["schema"]["ref"]["unique"] = False + self.resource["schema"]["ref"]["unique_to_user"] = True + + # first post as 'admin' is a success. + _, status = self.post() + self.assert201(status) + + # second post as 'admin' fails since value is not unique to user. + _, status = self.post() + self.assert422(status) + + self.resource["authentication"].request_auth_value = "alt" + # first post as 'alt' succeeds as value is unique to this user. + alt_auth = [("Authorization", "Basic YWx0OnNlY3JldA==")] + r = self.test_client.post( + self.url, data=self.data, headers=alt_auth, content_type="application/json" + ) + + self.assert201(r.status_code) + + # second post as 'alt' fails since value is not unique to user anymore. + r = self.test_client.post( + self.url, data=self.data, headers=alt_auth, content_type="application/json" + ) + + # post succeeds since value is unique to 'alt' user + self.assert422(r.status_code) + + def test_post_resource_auth(self): + # Ticket #231. + # Test that user restricted access works fine if there's no global + # level auth, which is set at resource level instead. + + # no global auth. + self.app = Eve(settings=self.settings_file) + + # set auth at resource level instead. + resource_def = self.app.config["DOMAIN"][self.url] + resource_def["authentication"] = ValidBasicAuth + resource_def["auth_field"] = "username" + + # post with valid auth - must store the document with the correct + # auth_field. + r = self.app.test_client().post( + self.url, + data=self.data, + headers=self.valid_auth, + content_type="application/json", + ) + _, status = self.parse_response(r) + + # Verify that we can retrieve the same document + data, status = self.parse_response( + self.app.test_client().get(self.url, headers=self.valid_auth) + ) + self.assert200(status) + self.assertEqual(len(data["_items"]), 1) + self.assertEqual(data["_items"][0]["ref"], json.loads(self.data)["ref"]) + + def test_post_bandwidth_saver_off_resource_auth(self): + """Test that when BANDWIDTH_SAVER is turned off the auth_field is + not exposed in the response payload + """ + self.app.config["BANDWIDTH_SAVER"] = False + r = self.app.test_client().post( + self.url, + data=self.data, + headers=self.valid_auth, + content_type="application/json", + ) + r, status = self.parse_response(r) + self.assertTrue("username" not in r) + + def test_put(self): + new_ref = "9999999999999999999999999" + changes = json.dumps({"ref": new_ref}) + + # post document + data, status = self.post() + + # retrieve document metadata + url = "%s/%s" % (self.url, data["_id"]) + response = self.test_client.get(url, headers=self.valid_auth) + etag = response.headers["ETag"] + + # perform put + headers = [("If-Match", etag), self.valid_auth[0]] + response, status = self.parse_response( + self.test_client.put( + url, + data=json.dumps(changes), + headers=headers, + content_type="application/json", + ) + ) + self.assert200(status) + etag = '"%s"' % response["_etag"] + + # document still accessible with same auth + data, status = self.parse_response( + self.test_client.get(url, headers=self.valid_auth) + ) + self.assert200(status) + self.assertEqual(data["ref"], new_ref) + + # put on same item with different auth fails + original_auth_val = self.resource["authentication"].request_auth_value + self.resource["authentication"].request_auth_value = "alt" + alt_auth = ("Authorization", "Basic YWx0OnNlY3JldA==") + alt_changes = {"ref": "1111111111111111111111111"} + headers = [("If-Match", etag), alt_auth] + response, status = self.parse_response( + self.test_client.put( + url, + data=json.dumps(alt_changes), + headers=headers, + content_type="application/json", + ) + ) + self.assert403(status) + + # document still accessible with original auth + self.resource["authentication"].request_auth_value = original_auth_val + data, status = self.parse_response( + self.test_client.get(url, headers=self.valid_auth) + ) + self.assert200(status) + self.assertEqual(data["ref"], new_ref) + + def test_put_resource_auth(self): + # no global auth. + self.app = Eve(settings=self.settings_file) + + # set auth at resource level instead. + resource_def = self.app.config["DOMAIN"][self.url] + resource_def["authentication"] = ValidBasicAuth + resource_def["auth_field"] = "username" + + # post + r = self.app.test_client().post( + self.url, + data=self.data, + headers=self.valid_auth, + content_type="application/json", + ) + data, status = self.parse_response(r) + + # retrieve document metadata + url = "%s/%s" % (self.url, data["_id"]) + response = self.app.test_client().get(url, headers=self.valid_auth) + etag = response.headers["ETag"] + + new_ref = "9999999999999999999999999" + changes = json.dumps({"ref": new_ref}) + + # put + headers = [("If-Match", etag), self.valid_auth[0]] + response, status = self.parse_response( + self.app.test_client().put( + url, + data=json.dumps(changes), + headers=headers, + content_type="application/json", + ) + ) + self.assert200(status) + etag = '"%s"' % response["_etag"] + + # document still accessible with same auth + data, status = self.parse_response( + self.app.test_client().get(url, headers=self.valid_auth) + ) + self.assert200(status) + self.assertEqual(data["ref"], new_ref) + + # put on same item with different auth fails + original_auth_val = resource_def["authentication"].request_auth_value + resource_def["authentication"].request_auth_value = "alt" + alt_auth = ("Authorization", "Basic YWx0OnNlY3JldA==") + alt_changes = {"ref": "1111111111111111111111111"} + headers = [("If-Match", etag), alt_auth] + response, status = self.parse_response( + self.app.test_client().put( + url, + data=json.dumps(alt_changes), + headers=headers, + content_type="application/json", + ) + ) + self.assert403(status) + + # document still accessible with original auth + resource_def["authentication"].request_auth_value = original_auth_val + data, status = self.parse_response( + self.app.test_client().get(url, headers=self.valid_auth) + ) + self.assert200(status) + self.assertEqual(data["ref"], new_ref) + + def test_put_bandwidth_saver_off_resource_auth(self): + """Test that when BANDWIDTH_SAVER is turned off the auth_field is + not exposed in the response payload + """ + self.app.config["BANDWIDTH_SAVER"] = False + + new_ref = "9999999999999999999999999" + changes = json.dumps({"ref": new_ref}) + + # post document + data, status = self.post() + + url = "%s/%s" % (self.url, data["_id"]) + + # perform put + headers = [("If-Match", data["_etag"]), self.valid_auth[0]] + response, status = self.parse_response( + self.test_client.put( + url, + data=json.dumps(changes), + headers=headers, + content_type="application/json", + ) + ) + self.assertTrue("username" not in response) + + def test_patch(self): + new_ref = "9999999999999999999999999" + changes = json.dumps({"ref": new_ref}) + data, status = self.post() + url = "%s/%s" % (self.url, data["_id"]) + response = self.test_client.get(url, headers=self.valid_auth) + etag = response.headers["ETag"] + headers = [("If-Match", etag), self.valid_auth[0]] + response, status = self.parse_response( + self.test_client.patch( + url, + data=json.dumps(changes), + headers=headers, + content_type="application/json", + ) + ) + self.assert200(status) + + data, status = self.parse_response( + self.test_client.get(url, headers=self.valid_auth) + ) + self.assert200(status) + self.assertEqual(data["ref"], new_ref) + + def test_delete(self): + _db = self.connection[MONGO_DBNAME] + + # make sure that other documents in the collections are untouched. + _db.contacts.find() + docs_num = _db.contacts.count_documents({}) + + _, _ = self.post() + + # after the post we only get back 1 document as it's the only one we + # inserted directly (others are filtered out). + response, status = self.parse_response( + self.test_client.get(self.url, headers=self.valid_auth) + ) + self.assert200(status) + self.assertEqual(len(response[self.app.config["ITEMS"]]), 1) + + # delete the document we just inserted + response, status = self.parse_response( + self.test_client.delete(self.url, headers=self.valid_auth) + ) + self.assert204(status) + + # we now get an empty items list (other documents in collection are + # filtered by auth). + response, status = self.parse_response( + self.test_client.get(self.url, headers=self.valid_auth) + ) + self.assert200(status) + # if it's a dict, we only got 1 item back which is expected + self.assertEqual(len(response[self.app.config["ITEMS"]]), 0) + + # make sure no other document has been deleted. + self.assertEqual(_db.contacts.count_documents({}), docs_num) + + def test_delete_item(self): + _db = self.connection[MONGO_DBNAME] + + # make sure that other documents in the collections are untouched. + docs_num = _db.contacts.count_documents({}) + + data, _ = self.post() + + # get back the document with its new etag + url = "%s/%s" % (self.url, data["_id"]) + response = self.test_client.get(url, headers=self.valid_auth) + etag = response.headers["ETag"] + headers = [("If-Match", etag), ("Authorization", "Basic YWRtaW46c2VjcmV0")] + + # delete the document + response, status = self.parse_response( + self.test_client.delete(url, headers=headers) + ) + self.assert204(status) + + # make sure no other document has been deleted. + self.assertEqual(_db.contacts.count_documents({}), docs_num) + + def test_delete_item_soft_delete_enabled(self): + self.app.config["DOMAIN"]["restricted"]["soft_delete"] = True + _db = self.connection[MONGO_DBNAME] + docs_num = _db.contacts.count_documents({}) + + data, _ = self.post() + + url = "%s/%s" % (self.url, data["_id"]) + response = self.test_client.get(url, headers=self.valid_auth) + etag = response.headers["ETag"] + headers = [("If-Match", etag), ("Authorization", "Basic YWRtaW46c2VjcmV0")] + + # delete the document + response, status = self.parse_response( + self.test_client.delete(url, headers=headers) + ) + self.assert204(status) + + # make sure no other document has been deleted. + self.assertEqual( + _db.contacts.count_documents({"_deleted": {"$ne": True}}), docs_num + ) + self.assertEqual(_db.contacts.count_documents({"_deleted": True}), 1) + + challenge = _db.contacts.find_one({"_deleted": True}) + self.assertEqual(challenge["username"], "admin") + + def post(self): + r = self.test_client.post( + self.url, + data=self.data, + headers=self.valid_auth, + content_type="application/json", + ) + return self.parse_response(r) diff --git a/tests/config.py b/tests/config.py new file mode 100644 index 000000000..6febe010c --- /dev/null +++ b/tests/config.py @@ -0,0 +1,559 @@ +# -*- coding: utf-8 -*- + +import os + +import eve +from eve.exceptions import ConfigException, SchemaException +from eve.flaskapp import Eve, RegexConverter +from eve.io.base import DataLayer +from eve.io.mongo import Mongo, Validator + +from . import TestBase +from .test_settings import MONGO_HOST, MONGO_PORT + + +class TestConfig(TestBase): + def test_allow_unknown_with_soft_delete(self): + my_settings = { + "ALLOW_UNKNOWN": True, + "SOFT_DELETE": True, + "DOMAIN": {"contacts": {}}, + } + try: + self.app = Eve(settings=my_settings) + except TypeError: + self.fail( + "ALLOW_UNKNOWN and SOFT_DELETE enabled should not cause " "a crash." + ) + + def test_default_import_name(self): + self.assertEqual(self.app.import_name, eve.__package__) + + def test_custom_import_name(self): + self.app = Eve("unittest", settings=self.settings_file) + self.assertEqual(self.app.import_name, "unittest") + + def test_custom_kwargs(self): + self.app = Eve("unittest", static_folder="static/", settings=self.settings_file) + self.assertTrue(self.app.static_folder.endswith("static")) + + def test_regexconverter(self): + regex_converter = self.app.url_map.converters.get("regex") + self.assertEqual(regex_converter, RegexConverter) + + def test_default_validator(self): + self.assertEqual(self.app.validator, Validator) + + def test_default_datalayer(self): + self.assertEqual(type(self.app.data), Mongo) + + def test_default_settings(self): + self.assertEqual(self.app.settings, self.settings_file) + + # TODO add tests for other global default values + self.assertEqual(self.app.config["RATE_LIMIT_GET"], None) + self.assertEqual(self.app.config["RATE_LIMIT_POST"], None) + self.assertEqual(self.app.config["RATE_LIMIT_PATCH"], None) + self.assertEqual(self.app.config["RATE_LIMIT_DELETE"], None) + + self.assertEqual(self.app.config["MONGO_HOST"], MONGO_HOST) + self.assertEqual(self.app.config["MONGO_PORT"], MONGO_PORT) + self.assertEqual(self.app.config["MONGO_QUERY_BLACKLIST"], ["$where", "$regex"]) + self.assertEqual(self.app.config["MONGO_QUERY_WHITELIST"], []) + self.assertEqual(self.app.config["MONGO_WRITE_CONCERN"], {"w": 1}) + self.assertEqual(self.app.config["ISSUES"], "_issues") + + self.assertEqual(self.app.config["OPLOG"], False) + self.assertEqual(self.app.config["OPLOG_NAME"], "oplog") + self.assertEqual(self.app.config["OPLOG_ENDPOINT"], None) + self.assertEqual(self.app.config["OPLOG_AUDIT"], True) + self.assertEqual( + self.app.config["OPLOG_METHODS"], ["DELETE", "POST", "PATCH", "PUT"] + ) + self.assertEqual( + self.app.config["OPLOG_CHANGE_METHODS"], ["DELETE", "PATCH", "PUT"] + ) + self.assertEqual(self.app.config["QUERY_WHERE"], "where") + self.assertEqual(self.app.config["QUERY_PROJECTION"], "projection") + self.assertEqual(self.app.config["QUERY_SORT"], "sort") + self.assertEqual(self.app.config["QUERY_PAGE"], "page") + self.assertEqual(self.app.config["QUERY_MAX_RESULTS"], "max_results") + self.assertEqual(self.app.config["QUERY_EMBEDDED"], "embedded") + self.assertEqual(self.app.config["QUERY_AGGREGATION"], "aggregate") + + self.assertEqual(self.app.config["JSON_SORT_KEYS"], False) + self.assertEqual(self.app.config["SOFT_DELETE"], False) + self.assertEqual(self.app.config["DELETED"], "_deleted") + self.assertEqual(self.app.config["SHOW_DELETED_PARAM"], "show_deleted") + self.assertEqual( + self.app.config["STANDARD_ERRORS"], + [400, 401, 403, 404, 405, 406, 409, 410, 412, 422, 428, 429], + ) + self.assertEqual(self.app.config["UPSERT_ON_PUT"], True) + self.assertEqual( + self.app.config["JSON_REQUEST_CONTENT_TYPES"], ["application/json"] + ) + self.assertEqual(self.app.config["NORMALIZE_DOTTED_FIELDS"], True) + self.assertEqual(self.app.config["OPTIMIZE_PAGINATION_FOR_SPEED"], False) + + def test_settings_as_dict(self): + my_settings = {"API_VERSION": "override!", "DOMAIN": {"contacts": {}}} + self.app = Eve(settings=my_settings) + self.assertEqual(self.app.config["API_VERSION"], "override!") + # did not reset other defaults + self.assertEqual(self.app.config["MONGO_WRITE_CONCERN"], {"w": 1}) + + def test_existing_env_config(self): + env = os.environ + os.environ = {"EVE_SETTINGS": "test_settings_env.py"} + self.app = Eve() + self.assertTrue("env_domain" in self.app.config["DOMAIN"]) + os.environ = env + + def test_unexisting_env_config(self): + env = os.environ + try: + os.environ = {"EVE_SETTINGS": "an_unexisting_pyfile.py"} + self.assertRaises(IOError, Eve) + finally: + os.environ = env + + def test_custom_validator(self): + class MyTestValidator(Validator): + pass + + self.app = Eve(validator=MyTestValidator, settings=self.settings_file) + self.assertEqual(self.app.validator, MyTestValidator) + + def test_custom_datalayer(self): + class MyTestDataLayer(DataLayer): + def init_app(self, app): + pass + + self.app = Eve(data=MyTestDataLayer, settings=self.settings_file) + self.assertEqual(type(self.app.data), MyTestDataLayer) + + def test_validate_domain_struct(self): + del self.app.config["DOMAIN"] + self.assertValidateConfigFailure("missing") + + self.app.config["DOMAIN"] = [] + self.assertValidateConfigFailure("must be a dict") + + self.app.config["DOMAIN"] = {} + self.assertValidateConfigSuccess() + + def test_validate_resource_methods(self): + self.app.config["RESOURCE_METHODS"] = ["PUT", "GET", "DELETE", "POST"] + self.assertValidateConfigFailure("PUT") + + def test_validate_item_methods(self): + self.app.config["ITEM_METHODS"] = ["PUT", "GET", "POST", "DELETE"] + self.assertValidateConfigFailure(["POST", "PUT"]) + + def test_validate_schema_methods(self): + test = {"resource_methods": ["PUT", "GET", "DELETE", "POST"]} + self.app.config["DOMAIN"]["test_resource"] = test + self.assertValidateConfigFailure("PUT") + + def test_validate_schema_item_methods(self): + test = {"resource_methods": ["GET"], "item_methods": ["POST"]} + self.app.config["DOMAIN"]["test_resource"] = test + self.assertValidateConfigFailure("PUT") + + def test_validate_datecreated_in_schema(self): + self.assertUnallowedField(eve.DATE_CREATED) + + def test_validate_lastupdated_in_schema(self): + self.assertUnallowedField(eve.LAST_UPDATED) + + def assertUnallowedField(self, field, field_type="datetime"): + self.domain.clear() + schema = {field: {"type": field_type}} + self.domain["resource"] = {"schema": schema} + self.app.set_defaults() + self.assertValidateSchemaFailure("resource", schema, field) + + def test_validate_schema(self): + # lack of 'collection' key for 'data_collection' rule + schema = self.domain["invoices"]["schema"] + del schema["person"]["data_relation"]["resource"] + self.assertValidateSchemaFailure("invoices", schema, "resource") + + def test_validate_invalid_field_names(self): + schema = self.domain["invoices"]["schema"] + schema["te$t"] = {"type": "string"} + self.assertValidateSchemaFailure("invoices", schema, "te$t") + del schema["te$t"] + + schema["te.t"] = {"type": "string"} + self.assertValidateSchemaFailure("invoices", schema, "te.t") + del schema["te.t"] + + schema["test_a_dict_schema"] = { + "type": "dict", + "schema": {"te$t": {"type": "string"}}, + } + self.assertValidateSchemaFailure("invoices", schema, "te$t") + + schema["test_a_dict_schema"]["schema"] = {"te.t": {"type": "string"}} + self.assertValidateSchemaFailure("invoices", schema, "te.t") + + def test_set_schema_defaults(self): + # default data_relation field value + schema = self.domain["invoices"]["schema"] + data_relation = schema["person"]["data_relation"] + self.assertTrue("field" in data_relation) + self.assertEqual(data_relation["field"], self.domain["contacts"]["id_field"]) + id_field = self.domain["invoices"]["id_field"] + self.assertTrue(id_field in schema) + self.assertEqual(schema[id_field], {"type": "objectid"}) + + def test_set_defaults(self): + self.domain.clear() + resource = "plurals" + self.domain[resource] = {} + self.app.set_defaults() + self._test_defaults_for_resource(resource) + settings = self.domain[resource] + self.assertEqual(len(settings["schema"]), 1) + + def _test_defaults_for_resource(self, resource): + settings = self.domain[resource] + self.assertEqual(settings["url"], resource) + self.assertEqual( + settings["internal_resource"], self.app.config["INTERNAL_RESOURCE"] + ) + self.assertEqual( + settings["resource_methods"], self.app.config["RESOURCE_METHODS"] + ) + self.assertEqual(settings["public_methods"], self.app.config["PUBLIC_METHODS"]) + self.assertEqual(settings["allowed_roles"], self.app.config["ALLOWED_ROLES"]) + self.assertEqual( + settings["allowed_read_roles"], self.app.config["ALLOWED_READ_ROLES"] + ) + self.assertEqual( + settings["allowed_write_roles"], self.app.config["ALLOWED_WRITE_ROLES"] + ) + self.assertEqual(settings["cache_control"], self.app.config["CACHE_CONTROL"]) + self.assertEqual(settings["cache_expires"], self.app.config["CACHE_EXPIRES"]) + self.assertEqual(settings["item_methods"], self.app.config["ITEM_METHODS"]) + self.assertEqual( + settings["public_item_methods"], self.app.config["PUBLIC_ITEM_METHODS"] + ) + self.assertEqual( + settings["allowed_item_roles"], self.app.config["ALLOWED_ITEM_ROLES"] + ) + self.assertEqual( + settings["allowed_item_read_roles"], + self.app.config["ALLOWED_ITEM_READ_ROLES"], + ) + self.assertEqual( + settings["allowed_item_write_roles"], + self.app.config["ALLOWED_ITEM_WRITE_ROLES"], + ) + self.assertEqual(settings["item_lookup"], self.app.config["ITEM_LOOKUP"]) + self.assertEqual( + settings["item_lookup_field"], self.app.config["ITEM_LOOKUP_FIELD"] + ) + self.assertEqual(settings["item_url"], self.app.config["ITEM_URL"]) + self.assertEqual(settings["item_title"], resource.rstrip("s").capitalize()) + self.assertEqual( + settings["allowed_filters"], self.app.config["ALLOWED_FILTERS"] + ) + self.assertEqual(settings["projection"], self.app.config["PROJECTION"]) + self.assertEqual(settings["versioning"], self.app.config["VERSIONING"]) + self.assertEqual(settings["soft_delete"], self.app.config["SOFT_DELETE"]) + self.assertEqual(settings["sorting"], self.app.config["SORTING"]) + self.assertEqual(settings["embedding"], self.app.config["EMBEDDING"]) + self.assertEqual(settings["pagination"], self.app.config["PAGINATION"]) + self.assertEqual(settings["auth_field"], self.app.config["AUTH_FIELD"]) + self.assertEqual(settings["allow_unknown"], self.app.config["ALLOW_UNKNOWN"]) + self.assertEqual( + settings["extra_response_fields"], self.app.config["EXTRA_RESPONSE_FIELDS"] + ) + self.assertEqual( + settings["mongo_query_whitelist"], self.app.config["MONGO_QUERY_WHITELIST"] + ) + self.assertEqual( + settings["mongo_write_concern"], self.app.config["MONGO_WRITE_CONCERN"] + ) + self.assertEqual(settings["resource_title"], settings["url"]) + + self.assertNotEqual(settings["schema"], None) + self.assertEqual(type(settings["schema"]), dict) + self.assertEqual(settings["etag_ignore_fields"], None) + self.assertEqual( + settings["optimize_pagination_for_speed"], + self.app.config["OPTIMIZE_PAGINATION_FOR_SPEED"] + ) + + def test_datasource(self): + self._test_datasource_for_resource("invoices") + + def _test_datasource_for_resource(self, resource): + datasource = self.domain[resource]["datasource"] + schema = self.domain[resource]["schema"] + compare = [key for key in datasource["projection"] if key in schema] + compare.extend( + [ + self.domain[resource]["id_field"], + self.app.config["LAST_UPDATED"], + self.app.config["DATE_CREATED"], + self.app.config["ETAG"], + ] + ) + + self.assertEqual( + datasource["projection"], dict((field, 1) for field in compare) + ) + self.assertEqual(datasource["source"], resource) + self.assertEqual(datasource["filter"], None) + + self.assertEqual(datasource["aggregation"], None) + + def test_validate_roles(self): + for resource in self.domain: + self.assertValidateRoles(resource, "allowed_roles") + self.assertValidateRoles(resource, "allowed_read_roles") + self.assertValidateRoles(resource, "allowed_write_roles") + self.assertValidateRoles(resource, "allowed_item_roles") + self.assertValidateRoles(resource, "allowed_item_read_roles") + self.assertValidateRoles(resource, "allowed_item_write_roles") + + def assertValidateRoles(self, resource, directive): + prev = self.domain[resource][directive] + self.domain[resource][directive] = "admin" + self.assertValidateConfigFailure(directive) + self.domain[resource][directive] = [] + self.assertValidateConfigSuccess() + self.domain[resource][directive] = ["admin", "dev"] + self.assertValidateConfigSuccess() + self.domain[resource][directive] = None + self.assertValidateConfigFailure(directive) + self.domain[resource][directive] = prev + + def assertValidateConfigSuccess(self): + try: + self.app.validate_domain_struct() + self.app.validate_config() + except ConfigException as e: + self.fail("ConfigException not expected: %s" % e) + + def assertValidateConfigFailure(self, expected): + try: + self.app.validate_domain_struct() + self.app.validate_config() + except ConfigException as e: + if isinstance(expected, str): + expected = [expected] + for exp in expected: + self.assertTrue(exp.lower() in str(e).lower()) + else: + self.fail("ConfigException expected but not raised.") + + def assertValidateSchemaFailure(self, resource, schema, expected): + try: + self.app.validate_schema(resource, schema) + except SchemaException as e: + self.assertTrue(expected.lower() in str(e).lower()) + else: + self.fail("SchemaException expected but not raised.") + + def test_url_helpers(self): + self.assertNotEqual(self.app.config.get("URLS"), None) + self.assertEqual(type(self.app.config["URLS"]), dict) + + self.assertNotEqual(self.app.config.get("SOURCES"), None) + self.assertEqual(type(self.app.config["SOURCES"]), dict) + + del self.domain["internal_transactions"] + for resource, settings in self.domain.items(): + self.assertEqual( + settings["datasource"], self.app.config["SOURCES"][resource] + ) + + def test_pretty_resource_urls(self): + """test that regexes are stripped out of urls and #466 is fixed.""" + resource_url = self.app.config["URLS"]["peopleinvoices"] + pretty_url = "users//invoices" + self.assertEqual(resource_url, pretty_url) + resource_url = self.app.config["URLS"]["peoplesearches"] + pretty_url = "users//saved_searches" + self.assertEqual(resource_url, pretty_url) + + def test_url_rules(self): + map_adapter = self.app.url_map.bind("") + + del self.domain["peopleinvoices"] + del self.domain["peoplerequiredinvoices"] + del self.domain["peoplesearches"] + del self.domain["internal_transactions"] + del self.domain["child_products"] + for _, settings in self.domain.items(): + for method in settings["resource_methods"]: + self.assertTrue(map_adapter.test("/%s/" % settings["url"], method)) + + # TODO test item endpoints as well. gonna be tricky since + # we have to reverse regexes here. will be fun. + + def test_register_resource(self): + resource = "resource" + settings = { + "schema": { + "title": {"type": "string", "default": "Mr."}, + "price": {"type": "integer", "default": 100}, + } + } + self.app.register_resource(resource, settings) + self._test_defaults_for_resource(resource) + self._test_datasource_for_resource(resource) + self.test_validate_roles() + + def test_auth_field_as_idfield(self): + resource = "resource" + settings = {"auth_field": self.app.config["ID_FIELD"]} + self.assertRaises( + ConfigException, self.app.register_resource, resource, settings + ) + + def test_auth_field_as_custom_idfield(self): + resource = "resource" + settings = { + "schema": {"id": {"type": "string"}}, + "id_field": "id", + "auth_field": "id", + } + self.assertRaises( + ConfigException, self.app.register_resource, resource, settings + ) + + def test_oplog_config(self): + # if OPLOG_ENDPOINT is enabled the endoint is included with the domain + self.app.config["OPLOG_ENDPOINT"] = "oplog" + self.app._init_oplog() + self.assertOplog("oplog", "oplog") + del self.domain["oplog"] + + # OPLOG can be also with a custom name (which will be used + # as the collection/table name on the db) + oplog = "custom" + self.app.config["OPLOG_NAME"] = oplog + self.app._init_oplog() + self.assertOplog(oplog, "oplog") + del self.domain[oplog] + + # oplog can be defined as a regular API endpoint, with a couple caveats + self.domain["oplog"] = { + "resource_methods": ["POST", "DELETE"], # not allowed + "resource_items": ["PATCH", "PUT"], # not allowed + "url": "custom_url", + "datasource": {"source": "customsource"}, + } + self.app.config["OPLOG_NAME"] = "oplog" + settings = self.domain["oplog"] + self.app._init_oplog() + + # endpoint is always read-only + self.assertEqual(settings["resource_methods"], ["GET"]) + self.assertEqual(settings["item_methods"], ["GET"]) + # other settings are customizable + self.assertEqual(settings["url"], "custom_url") + self.assertEqual(settings["datasource"]["source"], "customsource") + + def assertOplog(self, key, endpoint): + self.assertTrue(key in self.domain) + + settings = self.domain[key] + self.assertEqual(settings["resource_methods"], ["GET"]) + self.assertEqual(settings["item_methods"], ["GET"]) + self.assertEqual(settings["url"], endpoint) + self.assertEqual(settings["datasource"]["source"], key) + + def test_create_indexes(self): + # prepare a specific schema with mongo indexes declared + # along with the schema. + settings = { + "schema": { + "name": {"type": "string"}, + "other_field": {"type": "string"}, + "lat_long": {"type": "list"}, + }, + "versioning": True, + "mongo_indexes": { + "name": [("name", 1)], + "composed": [("name", 1), ("other_field", 1)], + "arguments": ([("lat_long", "2d")], {"sparse": True}), + }, + } + self.app.register_resource("mongodb_features", settings) + + # check that the indexes are there as a part of the resource + # settings + self.assertEqual( + self.app.config["DOMAIN"]["mongodb_features"]["mongo_indexes"], + settings["mongo_indexes"], + ) + + # check that the indexes were created + from pymongo import MongoClient + + db_name = self.app.config["MONGO_DBNAME"] + + db = MongoClient(host=MONGO_HOST, port=MONGO_PORT)[db_name] + for coll in [db["mongodb_features"], db["mongodb_features_versions"]]: + indexes = coll.index_information() + + # at least there is an index for the _id field plus the indexes + # created by the resource of this test + self.assertTrue(len(indexes) > len(settings["mongo_indexes"])) + + # check each one, fields involved and arguments given + for key, value in settings["mongo_indexes"].items(): + if isinstance(value, tuple): + fields, args = value + else: + fields = value + args = None + + self.assertTrue(key in indexes) + self.assertEqual(indexes[key]["key"], fields) + + for arg in args or (): + self.assertTrue(arg in indexes[key]) + self.assertEqual(args[arg], indexes[key][arg]) + + def test_custom_error_handlers(self): + """Test that the standard, custom error handler is registered for + supported error codes. + """ + codes = self.app.config["STANDARD_ERRORS"] + + # http://flask.pocoo.org/docs/0.10/api/#flask.Flask.error_handler_spec + handlers = self.app.error_handler_spec[None] + + challenge = lambda code: self.assertTrue(code in handlers) # noqa + map(challenge, codes) + + def test_mongodb_settings(self): + # Create custom app with mongodb settings. + settings = {"DOMAIN": {"contacts": {}}, "MONGO_OPTIONS": {"connect": False}} + app = Eve(settings=settings) + # Check if settings are set. + self.assertEqual( + app.config["MONGO_OPTIONS"]["connect"], app.config["MONGO_CONNECT"] + ) + # Prepare a specific schema with mongo specific settings. + settings = { + "schema": {"name": {"type": "string"}}, + "MONGO_OPTIONS": {"connect": False}, + } + self.app.register_resource("mongodb_settings", settings) + # check that settings are set. + resource_settings = self.app.config["DOMAIN"]["mongodb_settings"] + self.assertEqual(resource_settings["MONGO_OPTIONS"], settings["MONGO_OPTIONS"]) + # check that settings are set. + self.assertEqual( + resource_settings["MONGO_OPTIONS"]["connect"], + settings["MONGO_OPTIONS"]["connect"], + ) diff --git a/eve/tests/endpoints.py b/tests/endpoints.py similarity index 50% rename from eve/tests/endpoints.py rename to tests/endpoints.py index bd94effab..183c1fc62 100644 --- a/eve/tests/endpoints.py +++ b/tests/endpoints.py @@ -1,29 +1,32 @@ # -*- coding: utf-8 -*- +import os +from datetime import datetime +from uuid import UUID +import pytest import simplejson as json from werkzeug.routing import BaseConverter -from eve.tests import TestBase, TestMinimal + from eve import Eve -from datetime import datetime -from eve.utils import config from eve.io.base import BaseJSONEncoder -from eve.tests.test_settings import MONGO_DBNAME -from uuid import UUID from eve.io.mongo import Validator -import os +from eve.utils import config + +from . import TestBase, TestMinimal +from .test_settings import MONGO_DBNAME, MONGO_PASSWORD, MONGO_USERNAME class UUIDEncoder(BaseJSONEncoder): - """ Propretary JSONEconder subclass used by the json render function. + """Propretary JSONEconder subclass used by the json render function. This is different from BaseJSONEoncoder since it also addresses encoding of UUID """ + def default(self, obj): if isinstance(obj, UUID): return str(obj) - else: - # delegate rendering to base class method - return super(UUIDEncoder, self).default(obj) + # delegate rendering to base class method + return super().default(obj) class UUIDConverter(BaseConverter): @@ -32,7 +35,7 @@ class UUIDConverter(BaseConverter): """ def __init__(self, url_map, strict=True): - super(UUIDConverter, self).__init__(url_map) + super().__init__(url_map) def to_python(self, value): return UUID(value) @@ -45,12 +48,13 @@ class UUIDValidator(Validator): """ Extends the base mongo validator adding support for the uuid data-type """ - def _validate_type_uuid(self, field, value): + + def _validate_type_uuid(self, value): try: UUID(value) + return True except ValueError: - self._error("value '%s' for field '%s' cannot be converted to a " - "UUID" % (value, field)) + pass class TestCustomConverters(TestMinimal): @@ -60,29 +64,23 @@ class TestCustomConverters(TestMinimal): def setUp(self): uuids = { - 'resource_methods': ['GET', 'POST'], - 'item_methods': ['GET', 'PATCH', 'PUT', 'DELETE'], - 'item_url': 'uuid', - 'schema': { - '_id': {'type': 'uuid'}, - 'name': {'type': 'string'} - } + "resource_methods": ["GET", "POST"], + "item_methods": ["GET", "PATCH", "PUT", "DELETE"], + "item_url": "uuid", + "schema": {"_id": {"type": "uuid"}, "name": {"type": "string"}}, } settings = { - 'MONGO_USERNAME': 'test_user', - 'MONGO_PASSWORD': 'test_pw', - 'MONGO_DBNAME': 'eve_test', - 'DOMAIN': { - 'uuids': uuids - } + "MONGO_USERNAME": MONGO_USERNAME, + "MONGO_PASSWORD": MONGO_PASSWORD, + "MONGO_DBNAME": MONGO_DBNAME, + "DOMAIN": {"uuids": uuids}, } - url_converters = {'uuid': UUIDConverter} - self.uuid_valid = '48c00ee9-4dbe-413f-9fc3-d5f12a91de1c' - self.url = '/uuids/%s' % self.uuid_valid - self.headers = [('Content-Type', 'application/json')] + url_converters = {"uuid": UUIDConverter} + self.uuid_valid = "48c00ee9-4dbe-413f-9fc3-d5f12a91de1c" + self.url = "/uuids/%s" % self.uuid_valid + self.headers = [("Content-Type", "application/json")] - super(TestCustomConverters, self).setUp(settings_file=settings, - url_converters=url_converters) + super().setUp(settings_file=settings, url_converters=url_converters) self.app.validator = UUIDValidator self.app.data.json_encoder_class = UUIDEncoder @@ -91,8 +89,7 @@ def bulk_insert(self): # create a document which has a id field of UUID type and store it # into the database _db = self.connection[MONGO_DBNAME] - fake = {'_id': UUID(self.uuid_valid), } - _db.uuids.insert(fake) + _db.uuids.insert_one({"_id": UUID(self.uuid_valid)}) def _get_etag(self): r = self.test_client.get(self.url) @@ -105,61 +102,60 @@ def test_get_uuid(self): def test_patch_uuid(self): etag = self._get_etag() - self.headers.append(('If-Match', etag)) - r = self.test_client.patch(self.url, - data=json.dumps({"name": " a_name"}), - headers=self.headers) + self.headers.append(("If-Match", etag)) + r = self.test_client.patch( + self.url, data=json.dumps({"name": " a_name"}), headers=self.headers + ) self.assert200(r.status_code) def test_put_uuid(self): etag = self._get_etag() - self.headers.append(('If-Match', etag)) - r = self.test_client.put(self.url, - data=json.dumps({"name": " a_name"}), - headers=self.headers) + self.headers.append(("If-Match", etag)) + r = self.test_client.put( + self.url, data=json.dumps({"name": " a_name"}), headers=self.headers + ) self.assert200(r.status_code) def test_delete_uuid(self): etag = self._get_etag() - self.headers.append(('If-Match', etag)) + self.headers.append(("If-Match", etag)) r = self.test_client.delete(self.url, headers=self.headers) self.assert204(r.status_code) def test_post_uuid(self): - new_id = '48c00ee9-4dbe-413f-9fc3-d5f12a91de13' - data = json.dumps({'_id': new_id}) - r = self.test_client.post('uuids', data=data, headers=self.headers) + new_id = "48c00ee9-4dbe-413f-9fc3-d5f12a91de13" + data = json.dumps({"_id": new_id}) + r = self.test_client.post("uuids", data=data, headers=self.headers) self.assert201(r.status_code) - match_id = json.loads(r.get_data())['_id'] + match_id = json.loads(r.get_data())["_id"] self.assertEqual(new_id, match_id) class TestEndPoints(TestBase): - def test_homepage(self): - r = self.test_client.get('/') + r = self.test_client.get("/") self.assertEqual(r.status_code, 200) def test_resource_endpoint(self): - del(self.domain['peopleinvoices']) - del(self.domain['peoplerequiredinvoices']) - del(self.domain['peoplesearches']) - del(self.domain['internal_transactions']) - del(self.domain['child_products']) + del self.domain["peopleinvoices"] + del self.domain["peoplerequiredinvoices"] + del self.domain["peoplesearches"] + del self.domain["internal_transactions"] + del self.domain["child_products"] for settings in self.domain.values(): - r = self.test_client.get('/%s/' % settings['url']) + r = self.test_client.get("/%s/" % settings["url"]) self.assert200(r.status_code) - r = self.test_client.get('/%s' % settings['url']) + r = self.test_client.get("/%s" % settings["url"]) self.assert200(r.status_code) def assert_item_fields(self, data, resource=None): - id_field = self.domain[resource or self.known_resource]['id_field'] + id_field = self.domain[resource or self.known_resource]["id_field"] self.assertTrue(id_field in list(data)) - self.assertTrue('_created' in list(data)) - self.assertTrue('_updated' in list(data)) - self.assertTrue('_etag' in list(data)) - self.assertTrue('_links' in list(data)) + self.assertTrue("_created" in list(data)) + self.assertTrue("_updated" in list(data)) + self.assertTrue("_etag" in list(data)) + self.assertTrue("_links" in list(data)) def test_item_endpoint_id(self): data, status_code = self.get(self.known_resource, item=self.item_id) @@ -173,13 +169,12 @@ def test_item_endpoint_additional_lookup(self): def test_item_self_link(self): data, status_code = self.get(self.known_resource, item=self.item_id) - lookup_field = self.domain[self.known_resource]['item_lookup_field'] - link = '%s/%s' % (self.known_resource_url.lstrip('/'), - self.item[lookup_field]) - self.assertEqual(data.get('_links').get('self').get('href'), link) + lookup_field = self.domain[self.known_resource]["item_lookup_field"] + link = "%s/%s" % (self.known_resource_url.lstrip("/"), self.item[lookup_field]) + self.assertEqual(data.get("_links").get("self").get("href"), link) def test_unknown_endpoints(self): - r = self.test_client.get('/%s/' % self.unknown_resource) + r = self.test_client.get("/%s/" % self.unknown_resource) self.assert404(r.status_code) r = self.test_client.get(self.unknown_item_id_url) @@ -189,145 +184,163 @@ def test_unknown_endpoints(self): self.assert404(r.status_code) def test_api_version(self): - settings_file = os.path.join(self.this_directory, 'test_version.py') + settings_file = os.path.join(self.this_directory, "test_version.py") self.app = Eve(settings=settings_file) self.test_prefix = self.app.test_client() - r = self.test_prefix.get('/') + r = self.test_prefix.get("/") self.assert404(r.status_code) - r = self.test_prefix.get('/v1/') + r = self.test_prefix.get("/v1/") self.assert200(r.status_code) - r = self.test_prefix.get('/contacts/') + r = self.test_prefix.get("/contacts/") self.assert404(r.status_code) - r = self.test_prefix.get('/v1/contacts') + r = self.test_prefix.get("/v1/contacts") self.assert200(r.status_code) - r = self.test_prefix.get('/v1/contacts/') + r = self.test_prefix.get("/v1/contacts/") self.assert200(r.status_code) def test_api_prefix(self): - settings_file = os.path.join(self.this_directory, 'test_prefix.py') + settings_file = os.path.join(self.this_directory, "test_prefix.py") self.app = Eve(settings=settings_file) self.test_prefix = self.app.test_client() - r = self.test_prefix.get('/') + r = self.test_prefix.get("/") self.assert404(r.status_code) - r = self.test_prefix.get('/prefix/') + r = self.test_prefix.get("/prefix/") self.assert200(r.status_code) - r = self.test_prefix.get('/prefix/contacts') + r = self.test_prefix.get("/prefix/contacts") self.assert200(r.status_code) - r = self.test_prefix.get('/prefix/contacts/') + r = self.test_prefix.get("/prefix/contacts/") self.assert200(r.status_code) + r = self.test_prefix.post( + "/prefix/contacts/", data="{}", content_type="application/json" + ) + self.assert201(r.status_code) + + def test_api_prefix_post_internal(self): + # https://github.com/pyeve/eve/issues/810 + from eve.methods.post import post_internal + + settings_file = os.path.join(self.this_directory, "test_prefix.py") + self.app = Eve(settings=settings_file) + self.test_prefix = self.app.test_client() + + # This works fine + with self.app.test_request_context(method="POST", path="/prefix/contacts"): + _, _, _, status_code, _ = post_internal("contacts", {}) + self.assert201(status_code) + + # This fails unless #810 is fixed + with self.app.test_request_context(): + _, _, _, status_code, _ = post_internal("contacts", {}) + self.assert201(status_code) + def test_api_prefix_version(self): - settings_file = os.path.join(self.this_directory, - 'test_prefix_version.py') + settings_file = os.path.join(self.this_directory, "test_prefix_version.py") self.app = Eve(settings=settings_file) self.test_prefix = self.app.test_client() - r = self.test_prefix.get('/') + r = self.test_prefix.get("/") self.assert404(r.status_code) - r = self.test_prefix.get('/prefix/v1/') + r = self.test_prefix.get("/prefix/v1/") self.assert200(r.status_code) - r = self.test_prefix.get('/prefix/v1/contacts') + r = self.test_prefix.get("/prefix/v1/contacts") self.assert200(r.status_code) - r = self.test_prefix.get('/prefix/v1/contacts/') + r = self.test_prefix.get("/prefix/v1/contacts/") self.assert200(r.status_code) def test_api_prefix_version_hateoas_links(self): - """ Test that #419 is closed and URL_PREFIX and API_VERSION are stipped + """Test that #419 is closed and URL_PREFIX and API_VERSION are stipped out of hateoas links since they are now relative to the API entry point (root). """ - settings_file = os.path.join(self.this_directory, - 'test_prefix_version.py') + settings_file = os.path.join(self.this_directory, "test_prefix_version.py") self.app = Eve(settings=settings_file) self.test_prefix = self.app.test_client() - r = self.test_prefix.get('/prefix/v1/') - href = json.loads(r.get_data())['_links']['child'][0]['href'] - self.assertEqual(href, 'contacts') + r = self.test_prefix.get("/prefix/v1/") + href = json.loads(r.get_data())["_links"]["child"][0]["href"] + self.assertEqual(href, "contacts") - r = self.test_prefix.get('/prefix/v1/contacts') - href = json.loads(r.get_data())['_links']['self']['href'] - self.assertEqual(href, 'contacts') + r = self.test_prefix.get("/prefix/v1/contacts") + href = json.loads(r.get_data())["_links"]["self"]["href"] + self.assertEqual(href, "contacts") def test_nested_endpoint(self): - r = self.test_client.get('/users/overseas') + r = self.test_client.get("/users/overseas") self.assert200(r.status_code) def test_homepage_does_not_have_internal_resources(self): - r = self.test_client.get('/') + r = self.test_client.get("/") links = json.loads(r.get_data()) for resource in self.domain.keys(): - internal = self.domain[resource].get('internal_resource', False) + internal = self.domain[resource].get("internal_resource", False) if internal: self.assertFalse(internal in links.keys()) def on_generic_inserted(self, resource, docs): - if resource != 'internal_transactions': + if resource != "internal_transactions": dt = datetime.now() transaction = { - 'entities': [doc['_id'] for doc in docs], - 'original_resource': resource, + "entities": [doc["_id"] for doc in docs], + "original_resource": resource, config.LAST_UPDATED: dt, config.DATE_CREATED: dt, } - self.app.data.insert('internal_transactions', [transaction]) + self.app.data.insert("internal_transactions", transaction) def test_internal_endpoint(self): self.app.on_inserted -= self.on_generic_inserted self.app.on_inserted += self.on_generic_inserted - del(self.domain['contacts']['schema']['ref']['required']) + del self.domain["contacts"]["schema"]["ref"]["required"] test_field = "rows" - test_value = [ - {'sku': 'AT1234', 'price': 99}, - {'sku': 'XF9876', 'price': 9999} - ] + test_value = [{"sku": "AT1234", "price": 99}, {"sku": "XF9876", "price": 9999}] data = {test_field: test_value} resp_data, code = self.post(self.known_resource_url, data) self.assert201(code) def test_oplog_endpoint(self): - r = self.test_client.get('/oplog') + r = self.test_client.get("/oplog") self.assert404(r.status_code) - self.app.config['OPLOG_ENDPOINT'] = 'oplog' + self.app.config["OPLOG_ENDPOINT"] = "oplog" self.app._init_oplog() - settings = self.app.config['DOMAIN']['oplog'] - self.app.register_resource('oplog', settings) - r = self.test_client.get('/oplog') + settings = self.app.config["DOMAIN"]["oplog"] + self.app.register_resource("oplog", settings) + r = self.test_client.get("/oplog") self.assert200(r.status_code) # OPLOG endpoint is read-only - data = {'field': 'value'} - _, status_code = self.post('/oplog', data) + data = {"field": "value"} + _, status_code = self.post("/oplog", data) self.assert405(status_code) - _, status_code = self.delete('/oplog') + _, status_code = self.delete("/oplog") self.assert405(status_code) def test_schema_endpoint(self): - known_schema_path = '/schema/%s' % self.known_resource + known_schema_path = "/schema/%s" % self.known_resource r = self.test_client.get(known_schema_path) self.assert404(r.status_code) - self.app.config['SCHEMA_ENDPOINT'] = 'schema' + self.app.config["SCHEMA_ENDPOINT"] = "schema" self.app._init_schema_endpoint() r = self.test_client.get(known_schema_path) self.assert200(r.status_code) - self.assertEqual(r.mimetype, 'application/json') + self.assertEqual(r.mimetype, "application/json") self.assertEqual( - json.loads(r.data), - self.app.config['DOMAIN'][self.known_resource]['schema']) + json.loads(r.data), self.app.config["DOMAIN"][self.known_resource]["schema"] + ) - r = self.test_client.get('/schema/%s' % self.unknown_resource) + r = self.test_client.get("/schema/%s" % self.unknown_resource) self.assert404(r.status_code) # schema endpoint doesn't reveal internal resources - r = self.test_client.get('/schema/internal_transactions') + r = self.test_client.get("/schema/internal_transactions") self.assert404(r.status_code) # schema endpoint is read-only - data = {'field': 'value'} + data = {"field": "value"} _, status_code = self.patch(known_schema_path, data) self.assert405(status_code) _, status_code = self.put(known_schema_path, data) @@ -338,15 +351,16 @@ def test_schema_endpoint(self): self.assert405(status_code) def test_schema_endpoint_does_not_attempt_callable_serialization(self): - self.domain[self.known_resource]['schema']['lambda'] = { - 'type': 'boolean', - 'coerce': lambda v: v if type(v) is bool else v.lower() in ['true', - '1'] + self.domain[self.known_resource]["schema"]["lambda"] = { + "type": "boolean", + "coerce": lambda v: v + if isinstance(v, bool) + else v.lower() in ["true", "1"], } - known_schema_path = '/schema/%s' % self.known_resource - self.app.config['SCHEMA_ENDPOINT'] = 'schema' + known_schema_path = "/schema/%s" % self.known_resource + self.app.config["SCHEMA_ENDPOINT"] = "schema" self.app._init_schema_endpoint() r = self.test_client.get(known_schema_path) self.assert200(r.status_code) - self.assertEqual(json.loads(r.data)['lambda']['coerce'], '') + self.assertEqual(json.loads(r.data)["lambda"]["coerce"], "") diff --git a/eve/tests/io/__init__.py b/tests/methods/__init__.py similarity index 100% rename from eve/tests/io/__init__.py rename to tests/methods/__init__.py diff --git a/tests/methods/common.py b/tests/methods/common.py new file mode 100644 index 000000000..6831765d7 --- /dev/null +++ b/tests/methods/common.py @@ -0,0 +1,775 @@ +import time +from collections import OrderedDict # noqa +from datetime import datetime +from random import shuffle + +import simplejson as json +from bson import ObjectId, decimal128 +from bson.dbref import DBRef + +from eve.methods.common import normalize_dotted_fields, serialize, sort_per_resource +from eve.utils import config +from tests import TestBase +from tests.auth import ValidBasicAuth, ValidHMACAuth, ValidTokenAuth +from tests.suite_generator import EmbeddedDoc +from tests.test_settings import MONGO_DBNAME + + +class TestSerializer(TestBase): + def test_serialize_array_of_tipes(self): + # see #1112. + schema = { + "val": { + "type": "dict", + "schema": { + "x": {"type": ["string", "number"]}, + "timestamp": {"type": "datetime"}, + }, + } + } + + doc = {"val": {"x": "1", "timestamp": "Tue, 06 Nov 2012 10:33:31 GMT"}} + with self.app.app_context(): + serialized = serialize(doc, schema=schema) + self.assertEqual(serialized["val"]["x"], 1) + self.assertTrue(isinstance(serialized["val"]["timestamp"], datetime)) + + doc = {"val": {"x": "s", "timestamp": "Tue, 06 Nov 2012 10:33:31 GMT"}} + with self.app.app_context(): + serialized = serialize(doc, schema=schema) + self.assertEqual(serialized["val"]["x"], "s") + self.assertTrue(isinstance(serialized["val"]["timestamp"], datetime)) + + def test_serialize_subdocument(self): + # tests fix for #244, serialization of sub-documents. + schema = { + "personal": { + "type": "dict", + "schema": { + "best_friend": {"type": "objectid"}, + "born": {"type": "datetime"}, + }, + }, + "without_type": {}, + } + doc = { + "personal": { + "best_friend": "50656e4538345b39dd0414f0", + "born": "Tue, 06 Nov 2012 10:33:31 GMT", + }, + "without_type": "foo", + } + with self.app.app_context(): + serialized = serialize(doc, schema=schema) + self.assertTrue(isinstance(serialized["personal"]["best_friend"], ObjectId)) + self.assertTrue(isinstance(serialized["personal"]["born"], datetime)) + + def test_mongo_serializes(self): + schema = { + "id": {"type": "objectid"}, + "date": {"type": "datetime"}, + "count": {"type": "integer"}, + "average": {"type": "float"}, + "dict_valueschema": {"valueschema": {"type": "objectid"}}, + "refobj": {"type": "dbref"}, + "decobjstring": {"type": "decimal"}, + "decobjnumber": {"type": "decimal"}, + } + with self.app.app_context(): + # Success + res = serialize( + { + "id": "50656e4538345b39dd0414f0", + "date": "Tue, 06 Nov 2012 10:33:31 GMT", + "count": 42, + "average": 42.42, + "dict_valueschema": { + "foo1": "50656e4538345b39dd0414f0", + "foo2": "50656e4538345b39dd0414f0", + }, + "refobj": { + "$id": "50656e4538345b39dd0414f0", + "$col": "SomeCollection", + }, + "decobjstring": "200.0", + "decobjnumber": 200.0, + }, + schema=schema, + ) + self.assertTrue(isinstance(res["id"], ObjectId)) + self.assertTrue(isinstance(res["date"], datetime)) + self.assertTrue(isinstance(res["count"], int)) + self.assertTrue(isinstance(res["average"], float)) + + ks = res["dict_valueschema"] + self.assertTrue(isinstance(ks["foo1"], ObjectId)) + self.assertTrue(isinstance(ks["foo2"], ObjectId)) + self.assertTrue(isinstance(res["refobj"], DBRef)) + self.assertTrue(isinstance(res["decobjstring"], decimal128.Decimal128)) + self.assertTrue(isinstance(res["decobjnumber"], decimal128.Decimal128)) + + def test_non_blocking_on_simple_field_serialization_exception(self): + schema = { + "extract_time": {"type": "datetime"}, + "date": {"type": "datetime"}, + "total": {"type": "integer"}, + } + + with self.app.app_context(): + # Success + res = serialize( + { + "extract_time": "Tue, 06 Nov 2012 10:33:31 GMT", + "date": "Tue, 06 Nov 2012 10:33:31 GMT", + "total": "r123", + }, + schema=schema, + ) + # this has been left untouched as it could not be serialized. + self.assertEqual(res["total"], "r123") + # these have been both serialized. + self.assertTrue(isinstance(res["extract_time"], datetime)) + self.assertTrue(isinstance(res["date"], datetime)) + + def test_serialize_lists_of_lists(self): + # serialize should handle list of lists of basic types + schema = { + "l_of_l": { + "type": "list", + "schema": {"type": "list", "schema": {"type": "objectid"}}, + } + } + doc = { + "l_of_l": [ + ["50656e4538345b39dd0414f0", "50656e4538345b39dd0414f0"], + ["50656e4538345b39dd0414f0", "50656e4538345b39dd0414f0"], + ] + } + + with self.app.app_context(): + serialized = serialize(doc, schema=schema) + for sublist in serialized["l_of_l"]: + for item in sublist: + self.assertTrue(isinstance(item, ObjectId)) + + # serialize should handle list of lists of dicts + schema = { + "l_of_l": { + "type": "list", + "schema": { + "type": "list", + "schema": {"type": "dict", "schema": {"_id": {"type": "objectid"}}}, + }, + } + } + doc = { + "l_of_l": [ + [ + {"_id": "50656e4538345b39dd0414f0"}, + {"_id": "50656e4538345b39dd0414f0"}, + ], + [ + {"_id": "50656e4538345b39dd0414f0"}, + {"_id": "50656e4538345b39dd0414f0"}, + ], + ] + } + with self.app.app_context(): + serialized = serialize(doc, schema=schema) + for sublist in serialized["l_of_l"]: + for item in sublist: + self.assertTrue(isinstance(item["_id"], ObjectId)) + + def test_dbref_serialize_lists_of_lists(self): + # serialize should handle list of lists of basic types + schema = { + "l_of_l": { + "type": "list", + "schema": {"type": "list", "schema": {"type": "dbref"}}, + } + } + doc = { + "l_of_l": [ + [ + {"$col": "SomeCollection", "$id": "50656e4538345b39dd0414f0"}, + {"$col": "SomeCollection", "$id": "50656e4538345b39dd0414f0"}, + ], + [ + {"$col": "SomeCollection", "$id": "50656e4538345b39dd0414f0"}, + {"$col": "SomeCollection", "$id": "50656e4538345b39dd0414f0"}, + ], + ] + } + + with self.app.app_context(): + serialized = serialize(doc, schema=schema) + for sublist in serialized["l_of_l"]: + for item in sublist: + self.assertTrue(isinstance(item, DBRef)) + + # serialize should handle list of lists of dicts + schema = { + "l_of_l": { + "type": "list", + "schema": { + "type": "list", + "schema": {"type": "dict", "schema": {"_id": {"type": "dbref"}}}, + }, + } + } + doc = { + "l_of_l": [ + [ + { + "_id": { + "$col": "SomeCollection", + "$id": "50656e4538345b39dd0414f0", + } + }, + { + "_id": { + "$col": "SomeCollection", + "$id": "50656e4538345b39dd0414f0", + } + }, + ], + [ + { + "_id": { + "$col": "SomeCollection", + "$id": "50656e4538345b39dd0414f0", + } + }, + { + "_id": { + "$col": "SomeCollection", + "$id": "50656e4538345b39dd0414f0", + } + }, + ], + ] + } + with self.app.app_context(): + serialized = serialize(doc, schema=schema) + for sublist in serialized["l_of_l"]: + for item in sublist: + self.assertTrue(isinstance(item["_id"], DBRef)) + + def test_serialize_null_dictionary(self): + # Serialization should continue after encountering a null value dict + # field. Field may be nullable, or error will be caught in validation. + schema = { + "nullable_dict": { + "type": "dict", + "nullable": True, + "schema": {"simple_field": {"type": "number"}}, + } + } + doc = {"nullable_dict": None} + with self.app.app_context(): + try: + serialize(doc, schema=schema) + except Exception: + self.assertTrue( + False, + "Serializing null dictionaries should " "not raise an exception.", + ) + + def test_serialize_null_list(self): + schema = { + "nullable_list": { + "type": "list", + "nullable": True, + "schema": {"type": "objectid"}, + } + } + doc = {"nullable_list": None} + with self.app.app_context(): + try: + serialize(doc, schema=schema) + except Exception: + self.fail("Serializing null lists" " should not raise an exception") + + schema = { + "nullable_list": { + "type": "list", + "nullable": True, + "schema": {"type": "dbref"}, + } + } + doc = {"nullable_list": None} + with self.app.app_context(): + try: + serialize(doc, schema=schema) + except Exception: + self.fail("Serializing null lists" " should not raise an exception") + + def test_serialize_number(self): + schema = {"anumber": {"type": "number"}} + for expected_type, value in [(int, "35"), (float, "3.5")]: + doc = {"anumber": value} + with self.app.app_context(): + serialized = serialize(doc, schema=schema) + self.assertTrue(isinstance(serialized["anumber"], expected_type)) + + def test_serialize_boolean(self): + schema = {"bool": {"type": "boolean"}} + + with self.app.app_context(): + for val in [1, "1", 0, "0", "true", "True", "false", "False"]: + doc = {"bool": val} + serialized = serialize(doc, schema=schema) + self.assertTrue(isinstance(serialized["bool"], bool)) + + def test_serialize_inside_x_of_rules(self): + for x_of in ["allof", "anyof", "oneof", "noneof"]: + schema = {"x_of-field": {x_of: [{"type": "objectid"}, {"required": True}]}} + doc = {"x_of-field": "50656e4538345b39dd0414f0"} + with self.app.app_context(): + serialized = serialize(doc, schema=schema) + self.assertTrue(isinstance(serialized["x_of-field"], ObjectId)) + + def test_serialize_alongside_x_of_rules(self): + for x_of in ["allof", "anyof", "oneof", "noneof"]: + schema = OrderedDict( + [ + ("x_of-field", {x_of: [{"type": "objectid"}, {"required": True}]}), + ("oid-field", {"type": "objectid"}), + ] + ) + doc = OrderedDict( + [ + ("x_of-field", "50656e4538345b39dd0414f0"), + ("oid-field", "50656e4538345b39dd0414f0"), + ] + ) + with self.app.app_context(): + serialized = serialize(doc, schema=schema) + self.assertTrue(isinstance(serialized["x_of-field"], ObjectId)) + self.assertTrue(isinstance(serialized["oid-field"], ObjectId)) + + def test_serialize_list_alongside_x_of_rules(self): + for x_of in ["allof", "anyof", "oneof", "noneof"]: + schema = { + "x_of-field": { + "type": "list", + x_of: [ + {"schema": {"type": "objectid"}}, + {"schema": {"type": "datetime"}}, + ], + } + } + doc = {"x_of-field": ["50656e4538345b39dd0414f0"]} + with self.app.app_context(): + serialized = serialize(doc, schema=schema) + self.assertTrue(isinstance(serialized["x_of-field"][0], ObjectId)) + + def test_serialize_inside_nested_x_of_rules(self): + schema = { + "nested-x_of-field": { + "oneof": [ + { + "anyof": [{"type": "objectid"}, {"type": "datetime"}], + "required": True, + }, + {"allof": [{"type": "boolean"}, {"required": True}]}, + ] + } + } + doc = {"nested-x_of-field": "50656e4538345b39dd0414f0"} + with self.app.app_context(): + serialized = serialize(doc, schema=schema) + self.assertTrue(isinstance(serialized["nested-x_of-field"], ObjectId)) + + def test_serialize_inside_x_of_typesavers(self): + for x_of in ["allof", "anyof", "oneof", "noneof"]: + schema = { + "x_of-field": { + "{0}_type".format(x_of): ["objectid", "float", "boolean"] + } + } + doc = {"x_of-field": "50656e4538345b39dd0414f0"} + with self.app.app_context(): + serialized = serialize(doc, schema=schema) + self.assertTrue(isinstance(serialized["x_of-field"], ObjectId)) + + def test_serialize_inside_list_of_x_of_rules(self): + for x_of in ["allof", "anyof", "oneof", "noneof"]: + schema = { + "list-field": { + "type": "list", + "schema": {x_of: [{"type": "objectid", "required": True}]}, + } + } + doc = {"list-field": ["50656e4538345b39dd0414f0"]} + with self.app.app_context(): + serialized = serialize(doc, schema=schema) + serialized_oid = serialized["list-field"][0] + self.assertTrue(isinstance(serialized_oid, ObjectId)) + + def test_serialize_inside_list_of_schema_of_x_of_rules(self): + for x_of in ["allof", "anyof", "oneof", "noneof"]: + schema = { + "list-field": { + "type": "list", + "schema": { + x_of: [ + { + "type": "dict", + "schema": { + "x_of-field": {"type": "objectid", "required": True} + }, + } + ] + }, + } + } + doc = {"list-field": [{"x_of-field": "50656e4538345b39dd0414f0"}]} + with self.app.app_context(): + serialized = serialize(doc, schema=schema) + serialized_oid = serialized["list-field"][0]["x_of-field"] + self.assertTrue(isinstance(serialized_oid, ObjectId)) + + def test_serialize_inside_list_of_x_of_typesavers(self): + for x_of in ["allof", "anyof", "oneof", "noneof"]: + schema = { + "list-field": { + "type": "list", + "schema": { + "{0}_type".format(x_of): ["objectid", "float", "boolean"] + }, + } + } + doc = {"list-field": ["50656e4538345b39dd0414f0"]} + with self.app.app_context(): + serialized = serialize(doc, schema=schema) + serialized_oid = serialized["list-field"][0] + self.assertTrue(isinstance(serialized_oid, ObjectId)) + + +class TestNormalizeDottedFields(TestBase): + def test_normalize_dotted_fields(self): + def compare_recursive(a, b): + for key, value in a.items(): + if key not in b: + return False + if isinstance(value, dict): + compare_recursive(value, b[key]) + return True + + document = { + "a.b": 1, + "c.d": {"e.f": {"g": 1, "h": 2}, "e.f.i": {"j.k": 3}}, + "l": [{"m.n": 4}], + } + expected_result = { + "a": {"b": 1}, + "c": {"d": {"e": {"f": {"g": 1, "h": 2, "i": {"j": {"k": 3}}}}}}, + "l": [{"m": {"n": 4}}], + } + normalize_dotted_fields(document) + self.assertTrue(compare_recursive(document, expected_result)) + + +class TestOpLogBase(TestBase): + def setUp(self): + super().setUp() + self.test_field, self.test_value = "ref", "1234567890123456789054321" + self.data = {self.test_field: self.test_value} + self.test_client = self.app.test_client() + self.headers = [(("Content-Type", "application/json"))] + + def oplog_reset(self): + self.app._init_oplog() + self.app.register_resource("oplog", self.domain["oplog"]) + + settings = self.app.config["DOMAIN"]["oplog"] + datasource = settings["datasource"] + schema = settings["schema"] + datasource["projection"] = {} + self.app._set_resource_projection(datasource, schema, settings) + + def oplog_get(self, url="/oplog"): + r = self.test_client.get(url) + return self.parse_response(r) + + def assertOpLogEntry(self, entry, op, user=None): + self.assertTrue("r" in entry) + self.assertTrue("i" in entry) + self.assertTrue(config.LAST_UPDATED in entry) + self.assertTrue(config.DATE_CREATED in entry) + self.assertTrue("o" in entry) + self.assertEqual(entry["o"], op) + self.assertTrue("127.0.0.1" in entry["ip"]) + if op in self.app.config["OPLOG_CHANGE_METHODS"]: + self.assertTrue("c" in entry) + self.assertTrue("u" in entry) + if user: + self.assertTrue(user in entry["u"]) + else: + self.assertTrue("n/a" in entry["u"]) + + +class TestOpLogEndpointDisabled(TestOpLogBase): + def setUp(self): + super().setUp() + + self.app.config["OPLOG"] = True + from eve.default_settings import OPLOG_CHANGE_METHODS + + self.app.config["OPLOG_CHANGE_METHODS"] = OPLOG_CHANGE_METHODS + self.oplog_reset() + + def test_post_oplog(self): + r = self.test_client.post( + self.known_resource_url, + data=json.dumps(self.data), + headers=self.headers, + environ_base={"REMOTE_ADDR": "127.0.0.1"}, + ) + + # oplog endpoint is not available. + r, status = self.oplog_get() + self.assert404(status) + + # however the oplog collection has been updated. + db = self.connection[MONGO_DBNAME] + self.assertEqual(db.oplog.count_documents({}), 1) + self.assertOpLogEntry(db.oplog.find()[0], "POST") + + +class TestOpLogEndpointEnabled(TestOpLogBase): + def setUp(self): + super().setUp() + + self.app.config["OPLOG"] = True + self.app.config["OPLOG_ENDPOINT"] = "oplog" + self.oplog_reset() + + def test_oplog_hook(self): + def oplog_callback(resource, entries): + for entry in entries: + entry["extra"] = {"customfield": "customvalue"} + + self.app.on_oplog_push += oplog_callback + + r = self.test_client.post( + self.known_resource_url, + data=json.dumps(self.data), + headers=self.headers, + environ_base={"REMOTE_ADDR": "127.0.0.1"}, + ) + + # oplog enpoint does not expose the 'extra' field + r, status = self.oplog_get() + self.assert200(status) + self.assertEqual(len(r["_items"]), 1) + oplog_entry = r["_items"][0] + self.assertOpLogEntry(oplog_entry, "POST") + self.assertTrue("extra" not in oplog_entry) + + # however the oplog collection has the field. + db = self.connection[MONGO_DBNAME] + db.oplog.find() + self.assertEqual(db.oplog.count_documents({}), 1) + oplog_entry = db.oplog.find()[0] + self.assertTrue("extra" in oplog_entry) + self.assertTrue("customvalue" in oplog_entry["extra"]["customfield"]) + + # enable 'extra' field for the endpoint + self.app.config["OPLOG_RETURN_EXTRA_FIELD"] = True + self.oplog_reset() + + # now the oplog endpoint includes the 'extra' field + r, status = self.oplog_get() + self.assert200(status) + self.assertEqual(len(r["_items"]), 1) + oplog_entry = r["_items"][0] + self.assertOpLogEntry(oplog_entry, "POST") + self.assertTrue("extra" in oplog_entry) + self.assertTrue("customvalue" in oplog_entry["extra"]["customfield"]) + + def test_post_oplog(self): + r = self.test_client.post( + self.different_resource_url, + data=json.dumps({"username": "test", "ref": "1234567890123456789012345"}), + headers=self.headers, + environ_base={"REMOTE_ADDR": "127.0.0.1"}, + ) + + r, status = self.oplog_get() + self.assert200(status) + self.assertEqual(len(r["_items"]), 1) + oplog_entry = r["_items"][0] + self.assertOpLogEntry(oplog_entry, "POST") + self.assertTrue("extra" not in oplog_entry) + + def test_post_oplog_does_not_alter_document(self): + """Make sure we don't alter document ETag when performing an + oplog_push. See #590 and #1206.""" + self.app.config["OPLOG_CHANGE_METHODS"].append("POST") + r = self.test_client.post( + self.different_resource_url, + data=json.dumps({"username": "test", "ref": "1234567890123456789012345"}), + headers=self.headers, + environ_base={"REMOTE_ADDR": "127.0.0.1"}, + ) + + item_id = json.loads(r.get_data())["_id"] + etag1 = json.loads(r.get_data())["_etag"] + item, _ = self.get(self.different_resource, item=item_id) + etag2 = item["_etag"] + self.assertEqual(etag1, etag2) + + def test_patch_oplog(self): + self.headers.append(("If-Match", self.item_etag)) + r = self.test_client.patch( + self.item_id_url, + data=json.dumps(self.data), + headers=self.headers, + environ_base={"REMOTE_ADDR": "127.0.0.1"}, + ) + r, status = self.oplog_get() + self.assert200(status) + self.assertEqual(len(r["_items"]), 1) + oplog_entry = r["_items"][0] + self.assertOpLogEntry(oplog_entry, "PATCH") + + def test_put_oplog(self): + self.headers.append(("If-Match", self.item_etag)) + r = self.test_client.put( + self.item_id_url, + data=json.dumps(self.data), + headers=self.headers, + environ_base={"REMOTE_ADDR": "127.0.0.1"}, + ) + r, status = self.oplog_get() + self.assert200(status) + self.assertEqual(len(r["_items"]), 1) + oplog_entry = r["_items"][0] + self.assertOpLogEntry(oplog_entry, "PUT") + + def test_put_oplog_does_not_alter_document(self): + """Make sure we don't alter document ETag when performing an + oplog_push. See #590.""" + self.headers.append(("If-Match", self.item_etag)) + r = self.test_client.put( + self.item_id_url, + data=json.dumps(self.data), + headers=self.headers, + environ_base={"REMOTE_ADDR": "127.0.0.1"}, + ) + + etag1 = json.loads(r.get_data())["_etag"] + etag2 = json.loads(self.test_client.get(self.item_id_url).get_data())["_etag"] + self.assertEqual(etag1, etag2) + + def test_delete_oplog(self): + self.headers.append(("If-Match", self.item_etag)) + r = self.test_client.delete( + self.item_id_url, + headers=self.headers, + environ_base={"REMOTE_ADDR": "127.0.0.1"}, + ) + r, status = self.oplog_get() + self.assert200(status) + self.assertEqual(len(r["_items"]), 1) + oplog_entry = r["_items"][0] + self.assertOpLogEntry(oplog_entry, "DELETE") + + def test_soft_delete_oplog(self): + r, s = self.parse_response(self.test_client.get(self.item_id_url)) + doc_date = r[config.LAST_UPDATED] + time.sleep(1) + + self.domain[self.known_resource]["soft_delete"] = True + + self.headers.append(("If-Match", self.item_etag)) + r = self.test_client.delete( + self.item_id_url, + headers=self.headers, + environ_base={"REMOTE_ADDR": "127.0.0.1"}, + ) + r, status = self.oplog_get() + self.assert200(status) + self.assertEqual(len(r["_items"]), 1) + oplog_entry = r["_items"][0] + self.assertOpLogEntry(oplog_entry, "DELETE") + self.assertTrue(doc_date != oplog_entry[config.LAST_UPDATED]) + + def test_post_oplog_with_basic_auth(self): + self.domain["contacts"]["authentication"] = ValidBasicAuth + self.headers.append(("Authorization", "Basic YWRtaW46c2VjcmV0")) + r = self.test_client.post( + self.known_resource_url, + data=json.dumps(self.data), + headers=self.headers, + environ_base={"REMOTE_ADDR": "127.0.0.1"}, + ) + r, status = self.oplog_get() + self.assert200(status) + self.assertEqual(len(r["_items"]), 1) + oplog_entry = r["_items"][0] + self.assertOpLogEntry(oplog_entry, "POST", "admin") + + def test_post_oplog_with_token_auth(self): + self.domain["contacts"]["authentication"] = ValidTokenAuth + self.headers.append(("Authorization", "Basic dGVzdF90b2tlbjo=")) + r = self.test_client.post( + self.known_resource_url, + data=json.dumps(self.data), + headers=self.headers, + environ_base={"REMOTE_ADDR": "127.0.0.1"}, + ) + r, status = self.oplog_get() + self.assert200(status) + self.assertEqual(len(r["_items"]), 1) + oplog_entry = r["_items"][0] + self.assertOpLogEntry(oplog_entry, "POST", "test_token") + + def test_post_oplog_with_hmac_auth(self): + self.domain["contacts"]["authentication"] = ValidHMACAuth + self.headers.append(("Authorization", "admin:secret")) + r = self.test_client.post( + self.known_resource_url, + data=json.dumps(self.data), + headers=self.headers, + environ_base={"REMOTE_ADDR": "127.0.0.1"}, + ) + r, status = self.oplog_get() + self.assert200(status) + self.assertEqual(len(r["_items"]), 1) + oplog_entry = r["_items"][0] + self.assertOpLogEntry(oplog_entry, "POST", "admin") + + def patch(self, url, data, headers=[], content_type="application/json"): + headers.append(("Content-Type", content_type)) + headers.append(("If-Match", self.item_etag)) + r = self.test_client.patch(url, data=json.dumps(data), headers=headers) + return self.parse_response(r) + + def put(self, url, data, headers=[], content_type="application/json"): + headers.append(("Content-Type", content_type)) + headers.append(("If-Match", self.item_etag)) + r = self.test_client.put(url, data=json.dumps(data), headers=headers) + return self.parse_response(r) + + +class TestTickets(TestBase): + def test_ticket_681(self): + # See https://github.com/pyeve/eve/issues/681 + with self.app.test_request_context("not_an_existing_endpoint"): + self.app.data.driver.db["again"] + + +class TestEmbeddedDocuments(TestBase): + def setUp(self, url_converters=None): + super().setUp() + + def test_sort_per_resource_embedded_docs(self): + object_ids = [ObjectId() for _ in range(8)] + embedded_docs = [EmbeddedDoc(_id=_id).__dict__ for _id in object_ids] + + shuffle(object_ids) + sorted_docs = sort_per_resource(embedded_docs, object_ids[:7], "_id") + self.assertEqual(len(sorted_docs), 7) diff --git a/eve/tests/methods/delete.py b/tests/methods/delete.py similarity index 65% rename from eve/tests/methods/delete.py rename to tests/methods/delete.py index cb45464ca..c7a66d091 100644 --- a/eve/tests/methods/delete.py +++ b/tests/methods/delete.py @@ -1,57 +1,83 @@ import copy -import simplejson as json +import simplejson as json from bson import ObjectId + from eve import ETAG from eve.methods.delete import deleteitem_internal -from eve.tests import TestBase -from eve.tests.test_settings import MONGO_DBNAME -from eve.tests.utils import DummyEvent from eve.utils import ParsedRequest +from tests import TestBase +from tests.test_settings import MONGO_DBNAME +from tests.utils import DummyEvent class TestDelete(TestBase): def setUp(self): - super(TestDelete, self).setUp() + super().setUp() # Etag used to delete an item (a contact) - self.etag_headers = [('If-Match', self.item_etag)] + self.etag_headers = [("If-Match", self.item_etag)] def test_unknown_resource(self): - url = '%s%s/' % (self.unknown_resource_url, self.item_id) + url = "%s%s/" % (self.unknown_resource_url, self.item_id) + _, status = self.delete(url) + self.assert404(status) + + def test_bulk_delete_id_field(self): + etag_check = self.app.config["IF_MATCH"] + self.app.config["IF_MATCH"] = False + products, _ = self.get(self.products) + list_products_skus = [ + product["parent_product"] + for product in products["_items"] + if "parent_product" in product + ] + # Deletion of all the product in the first cart + url = self.child_products_url.replace( + '', list_products_skus[0] + ) _, status = self.delete(url) + self.assert204(status) + _, status = self.get(url) self.assert404(status) + products_url = "%s/%s" % (self.products, list_products_skus[0]) + _, status = self.delete(products_url) + self.assert204(status) + _, status = self.get(products_url) + self.assert404(status) + _, status = self.get(self.products) + self.assert200(status) + self.app.config["IF_MATCH"] = etag_check def test_delete_from_resource_endpoint(self): r, status = self.delete(self.known_resource_url) self.assert204(status) - r, status = self.parse_response(self.test_client.get( - self.known_resource_url)) + r, status = self.parse_response(self.test_client.get(self.known_resource_url)) self.assert200(status) - self.assertEqual(len(r['_items']), 0) + self.assertEqual(len(r["_items"]), 0) def test_delete_from_resource_endpoint_write_concern(self): # should get a 500 since there's no replicaset on the mongod instance - self.domain['contacts']['mongo_write_concern'] = {'w': 2} + self.domain["contacts"]["mongo_write_concern"] = {"w": 2} _, status = self.delete(self.known_resource_url) self.assert500(status) def test_delete_from_resource_endpoint_different_resource(self): r, status = self.delete(self.different_resource_url) self.assert204(status) - r, status = self.parse_response(self.test_client.get( - self.different_resource_url)) + r, status = self.parse_response( + self.test_client.get(self.different_resource_url) + ) self.assert200(status) - self.assertEqual(len(r['_items']), 0) + self.assertEqual(len(r["_items"]), 0) # deletion of 'users' will still lave 'contacts' untouched (same db # collection) - r, status = self.parse_response(self.test_client.get( - self.known_resource_url)) + r, status = self.parse_response(self.test_client.get(self.known_resource_url)) self.assert200(status) - self.assertEqual(len(r['_items']), 25) + self.assertEqual(len(r["_items"]), 25) def test_delete_empty_resource(self): - url = '%s%s/' % (self.empty_resource_url, self.item_id) + url = "%s%s/" % (self.empty_resource_url, self.item_id) _, status = self.delete(url) self.assert404(status) @@ -60,12 +86,12 @@ def test_delete_readonly_resource(self): self.assert405(status) def test_delete_readonly_resource_with_override(self): - headers = [('X-HTTP-Method-Override', 'DELETE')] + headers = [("X-HTTP-Method-Override", "DELETE")] r = self.test_client.get(self.readonly_resource_url, headers=headers) self.assert405(r.status_code) def test_delete_unknown_item(self): - url = '%s%s/' % (self.known_resource_url, self.unknown_item_id) + url = "%s%s/" % (self.known_resource_url, self.unknown_item_id) _, status = self.delete(url) self.assert404(status) @@ -74,7 +100,7 @@ def test_delete_ifmatch_missing(self): self.assert428(status) def test_ifmatch_missing_enforce_ifmatch_disabled(self): - self.app.config['ENFORCE_IF_MATCH'] = False + self.app.config["ENFORCE_IF_MATCH"] = False r, status = self.delete(self.item_id_url) self.assert204(status) @@ -82,13 +108,13 @@ def test_ifmatch_missing_enforce_ifmatch_disabled(self): self.assert404(r.status_code) def test_delete_ifmatch_disabled(self): - self.app.config['IF_MATCH'] = False + self.app.config["IF_MATCH"] = False _, status = self.delete(self.item_id_url) self.assert204(status) def test_ifmatch_disabled_enforce_ifmatch_disabled(self): - self.app.config['ENFORCE_IF_MATCH'] = False - self.app.config['IF_MATCH'] = False + self.app.config["ENFORCE_IF_MATCH"] = False + self.app.config["IF_MATCH"] = False r, status = self.delete(self.item_id_url) self.assert204(status) @@ -96,15 +122,15 @@ def test_ifmatch_disabled_enforce_ifmatch_disabled(self): self.assert404(r.status_code) def test_delete_ifmatch_bad_etag(self): - _, status = self.delete(self.item_id_url, - headers=[('If-Match', 'not-quite-right')]) + _, status = self.delete( + self.item_id_url, headers=[("If-Match", "not-quite-right")] + ) self.assert412(status) def test_ifmatch_bad_etag_enforce_ifmatch_disabled(self): - self.app.config['ENFORCE_IF_MATCH'] = False + self.app.config["ENFORCE_IF_MATCH"] = False _, status = self.delete( - self.item_id_url, - headers=[('If-Match', 'not-quite-right')] + self.item_id_url, headers=[("If-Match", "not-quite-right")] ) self.assert412(status) @@ -118,18 +144,20 @@ def test_delete(self): def test_delete_non_existant(self): url = self.item_id_url[:-5] + "00000" r, status = self.delete(url, headers=self.etag_headers) - self.assert404(status) + self.assert204(status) def test_delete_write_concern(self): # should get a 500 since there's no replicaset on the mongod instance - self.domain['contacts']['mongo_write_concern'] = {'w': 2} - _, status = self.delete(self.item_id_url, - headers=[('If-Match', self.item_etag)]) + self.domain["contacts"]["mongo_write_concern"] = {"w": 2} + _, status = self.delete( + self.item_id_url, headers=[("If-Match", self.item_etag)] + ) self.assert500(status) def test_delete_different_resource(self): - r, status = self.delete(self.user_id_url, - headers=[('If-Match', self.user_etag)]) + r, status = self.delete( + self.user_id_url, headers=[("If-Match", self.user_etag)] + ) self.assert204(status) r = self.test_client.get(self.user_id_url) @@ -137,8 +165,7 @@ def test_delete_different_resource(self): def test_delete_with_post_override(self): # POST request with DELETE override turns into a DELETE - headers = [('X-HTTP-Method-Override', 'DELETE'), - ('If-Match', self.item_etag)] + headers = [("X-HTTP-Method-Override", "DELETE"), ("If-Match", self.item_etag)] r = self.test_client.post(self.item_id_url, data={}, headers=headers) self.assert204(r.status_code) @@ -146,76 +173,78 @@ def test_delete_subresource(self): _db = self.connection[MONGO_DBNAME] # create random contact - fake_contact = self.random_contacts(1) - fake_contact_id = _db.contacts.insert(fake_contact)[0] + fake_contact = self.random_contacts(1)[0] + fake_contact_id = _db.contacts.insert_one(fake_contact).inserted_id # grab parent collection count; we will use this later to make sure we # didn't delete all the users in the datanase. We add one extra invoice # to make sure that the actual count will never be 1 (which would # invalidate the test) - _db.invoices.insert({'inv_number': 1}) - response, status = self.get('invoices') - invoices = len(response[self.app.config['ITEMS']]) + _db.invoices.insert_one({"inv_number": 1}) + response, status = self.get("invoices") + invoices = len(response[self.app.config["ITEMS"]]) # update first invoice to reference the new contact - _db.invoices.update({'_id': ObjectId(self.invoice_id)}, - {'$set': {'person': fake_contact_id}}) + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, {"$set": {"person": fake_contact_id}} + ) # verify that the only document retrieved is referencing the correct # parent document - response, status = self.get('users/%s/invoices' % fake_contact_id) - person_id = ObjectId(response[self.app.config['ITEMS']][0]['person']) + response, status = self.get("users/%s/invoices" % fake_contact_id) + person_id = ObjectId(response[self.app.config["ITEMS"]][0]["person"]) self.assertEqual(person_id, fake_contact_id) # delete all documents at the sub-resource endpoint - response, status = self.delete('users/%s/invoices' % fake_contact_id) + response, status = self.delete("users/%s/invoices" % fake_contact_id) self.assert204(status) # verify that the no documents are left at the sub-resource endpoint - response, status = self.get('users/%s/invoices' % fake_contact_id) - self.assertEqual(len(response['_items']), 0) + response, status = self.get("users/%s/invoices" % fake_contact_id) + self.assertEqual(len(response["_items"]), 0) # verify that other documents in the invoices collection have not neen # deleted - response, status = self.get('invoices') - self.assertEqual(len(response['_items']), invoices - 1) + response, status = self.get("invoices") + self.assertEqual(len(response["_items"]), invoices - 1) def test_delete_subresource_item(self): _db = self.connection[MONGO_DBNAME] # create random contact - fake_contact = self.random_contacts(1) - fake_contact_id = _db.contacts.insert(fake_contact)[0] + fake_contact = self.random_contacts(1)[0] + fake_contact_id = _db.contacts.insert_one(fake_contact).inserted_id # update first invoice to reference the new contact - _db.invoices.update({'_id': ObjectId(self.invoice_id)}, - {'$set': {'person': fake_contact_id}}) + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, {"$set": {"person": fake_contact_id}} + ) # GET all invoices by new contact - response, status = self.get('users/%s/invoices/%s' % - (fake_contact_id, self.invoice_id)) + response, status = self.get( + "users/%s/invoices/%s" % (fake_contact_id, self.invoice_id) + ) etag = response[ETAG] - headers = [('If-Match', etag)] - response, status = self.delete('users/%s/invoices/%s' % - (fake_contact_id, self.invoice_id), - headers=headers) + headers = [("If-Match", etag)] + response, status = self.delete( + "users/%s/invoices/%s" % (fake_contact_id, self.invoice_id), headers=headers + ) self.assert204(status) def test_delete_custom_idfield(self): - response, status = self.get('products?max_results=1') - product = response['_items'][0] - headers = [('If-Match', product[ETAG])] - response, status = self.delete('products/%s' % product['sku'], - headers=headers) + response, status = self.get("products?max_results=1") + product = response["_items"][0] + headers = [("If-Match", product[ETAG])] + response, status = self.delete("products/%s" % product["sku"], headers=headers) self.assert204(status) def test_deleteitem_internal(self): # test that deleteitem_internal is available and working properly. with self.app.test_request_context(self.item_id_url): r, _, _, status = deleteitem_internal( - self.known_resource, concurrency_check=False, - **{'_id': self.item_id}) + self.known_resource, concurrency_check=False, **{"_id": self.item_id} + ) self.assert204(status) r = self.test_client.get(self.item_id_url) @@ -228,18 +257,18 @@ def delete(self, url, headers=None): class TestSoftDelete(TestDelete): def setUp(self): - super(TestSoftDelete, self).setUp() + super().setUp() # Enable soft delete - self.app.config['SOFT_DELETE'] = True + self.app.config["SOFT_DELETE"] = True domain = copy.copy(self.domain) for resource, settings in domain.items(): # rebuild resource settings for soft delete - del settings['soft_delete'] + del settings["soft_delete"] self.app.register_resource(resource, settings) # alias for the configured DELETED field name - self.deleted_field = self.app.config['DELETED'] + self.deleted_field = self.app.config["DELETED"] # TestDelete overrides @@ -257,18 +286,17 @@ def test_delete(self): self.assert404(status) self.assertEqual(data.get(self.deleted_field), True) - self.assertNotEqual(data.get('_etag'), self.item_etag) + self.assertNotEqual(data.get("_etag"), self.item_etag) # 404 should still include a status and an error field - self.assertTrue(self.app.config['ERROR'] in data) + self.assertTrue(self.app.config["ERROR"] in data) def test_deleteitem_internal(self): - """Deleteitem internal should honor soft delete settings. - """ + """Deleteitem internal should honor soft delete settings.""" # test that deleteitem_internal is available and working properly. with self.app.test_request_context(self.item_id_url): r, _, _, status = deleteitem_internal( - self.known_resource, concurrency_check=False, - **{'_id': self.item_id}) + self.known_resource, concurrency_check=False, **{"_id": self.item_id} + ) self.assert204(status) r = self.test_client.get(self.item_id_url) @@ -277,8 +305,9 @@ def test_deleteitem_internal(self): self.assertEqual(data.get(self.deleted_field), True) def test_delete_different_resource(self): - r, status = self.delete(self.user_id_url, - headers=[('If-Match', self.user_etag)]) + r, status = self.delete( + self.user_id_url, headers=[("If-Match", self.user_etag)] + ) self.assert204(status) r = self.test_client.get(self.user_id_url) @@ -293,7 +322,7 @@ def test_delete_from_resource_endpoint(self): """ # TestDelete deletes resource at known_resource_url, and confirms # subsequent queries to the resource return zero items - super(TestSoftDelete, self).test_delete_from_resource_endpoint() + super().test_delete_from_resource_endpoint() r = self.test_client.get(self.item_id_url) data, status = self.parse_response(r) @@ -306,35 +335,35 @@ def test_restore_softdeleted(self): """Sending a PUT or PATCH to a soft deleted document should restore the document. """ + def soft_delete_item(etag): - r, status = self.delete( - self.item_id_url, headers=[('If-Match', etag)]) + r, status = self.delete(self.item_id_url, headers=[("If-Match", etag)]) self.assert204(status) # GET soft deleted etag return self.test_client.get(self.item_id_url) # Restore via PATCH - deleted_etag = soft_delete_item(self.item_etag).headers['ETag'] + deleted_etag = soft_delete_item(self.item_etag).headers["ETag"] r = self.test_client.patch( self.item_id_url, data=json.dumps({}), - headers=[('Content-Type', 'application/json'), - ('If-Match', deleted_etag)]) + headers=[("Content-Type", "application/json"), ("If-Match", deleted_etag)], + ) self.assert200(r.status_code) r = self.test_client.get(self.item_id_url) self.assert200(r.status_code) - new_etag = r.headers['ETag'] + new_etag = r.headers["ETag"] # Restore via PUT r = soft_delete_item(new_etag) - deleted_etag = r.headers['ETag'] + deleted_etag = r.headers["ETag"] restored_doc = {"ref": "1234567890123456789012345"} r = self.test_client.put( self.item_id_url, data=json.dumps(restored_doc), - headers=[('Content-Type', 'application/json'), - ('If-Match', deleted_etag)]) + headers=[("Content-Type", "application/json"), ("If-Match", deleted_etag)], + ) self.assert200(r.status_code) r = self.test_client.get(self.item_id_url) @@ -342,18 +371,17 @@ def soft_delete_item(etag): def test_multiple_softdelete(self): """After an item has been soft deleted, subsequent DELETEs should - return a 404 Not Found response. + return a 204 Not Found response. """ r, status = self.delete(self.item_id_url, headers=self.etag_headers) self.assert204(status) # GET soft deleted etag r = self.test_client.get(self.item_id_url) - new_etag = r.headers['ETag'] + new_etag = r.headers["ETag"] # Second soft DELETE should return 404 Not Found - r, status = self.delete( - self.item_id_url, headers=[('If-Match', new_etag)]) - self.assert404(status) + r, status = self.delete(self.item_id_url, headers=[("If-Match", new_etag)]) + self.assert204(status) def test_softdelete_deleted_field(self): """The configured 'deleted' field should be added to all documents to indicate @@ -373,27 +401,25 @@ def test_softdelete_show_deleted(self): self.assert204(status) data, status = self.get(self.known_resource) - after_softdelete_count = data[self.app.config['META']]['total'] + after_softdelete_count = data[self.app.config["META"]]["total"] self.assertEqual(after_softdelete_count, self.known_resource_count - 1) data, status = self.get(self.known_resource, query="?show_deleted") - show_deleted_count = data[self.app.config['META']]['total'] + show_deleted_count = data[self.app.config["META"]]["total"] self.assertEqual(show_deleted_count, self.known_resource_count) # Test show_deleted with additional queries - role_query = '?where={"role": "' + self.item['role'] + '"}' + role_query = '?where={"role": "' + self.item["role"] + '"}' data, status = self.get(self.known_resource, query=role_query) - role_count = data[self.app.config['META']]['total'] + role_count = data[self.app.config["META"]]["total"] - data, status = self.get( - self.known_resource, query=role_query + "&show_deleted") - show_deleted_role_count = data[self.app.config['META']]['total'] + data, status = self.get(self.known_resource, query=role_query + "&show_deleted") + show_deleted_role_count = data[self.app.config["META"]]["total"] self.assertEqual(show_deleted_role_count, role_count + 1) # Test explicit _deleted query - data, status = self.get( - self.known_resource, query='?where={"_deleted": true}') - deleted_query_count = data[self.app.config['META']]['total'] + data, status = self.get(self.known_resource, query='?where={"_deleted": true}') + deleted_query_count = data[self.app.config["META"]]["total"] self.assertEqual(deleted_query_count, 1) def test_softdeleted_embedded_doc(self): @@ -403,38 +429,39 @@ def test_softdeleted_embedded_doc(self): """ # Set up and confirm embedded document _db = self.connection[MONGO_DBNAME] - fake_contact = self.random_contacts(1) - fake_contact_id = _db.contacts.insert(fake_contact)[0] + fake_contact = self.random_contacts(1)[0] + fake_contact_id = _db.contacts.insert_one(fake_contact).inserted_id fake_contact_url = self.known_resource_url + "/" + str(fake_contact_id) - _db.invoices.update({'_id': ObjectId(self.invoice_id)}, - {'$set': {'person': fake_contact_id}}) + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, {"$set": {"person": fake_contact_id}} + ) - invoices = self.domain['invoices'] - invoices['embedding'] = True - invoices['schema']['person']['data_relation']['embeddable'] = True - embedded = '{"person": 1}' + invoices = self.domain["invoices"] + invoices["embedding"] = True + invoices["schema"]["person"]["data_relation"]["embeddable"] = True + self.clearSchemaCache() - r = self.test_client.get( - self.invoice_id_url + '?embedded=%s' % embedded) + embedded = '{"person": 1}' + r = self.test_client.get(self.invoice_id_url + "?embedded=%s" % embedded) data, status = self.parse_response(r) self.assert200(status) - self.assertTrue('location' in data['person']) + self.assertTrue("location" in data["person"]) # Get embedded doc etag so we can delete it r = self.test_client.get(fake_contact_url) - embedded_contact_etag = r.headers['ETag'] + embedded_contact_etag = r.headers["ETag"] # Delete embedded contact data, status = self.delete( - fake_contact_url, headers=[('If-Match', embedded_contact_etag)]) + fake_contact_url, headers=[("If-Match", embedded_contact_etag)] + ) self.assert204(status) # embedded 'person' should now be empty - r = self.test_client.get( - self.invoice_id_url + '?embedded=%s' % embedded) + r = self.test_client.get(self.invoice_id_url + "?embedded=%s" % embedded) data, status = self.parse_response(r) self.assert200(status) - self.assertEqual(data['person'], None) + self.assertEqual(data["person"], None) def test_softdeleted_get_response_skips_embedded_expansion(self): """Soft deleted documents should not expand their embedded documents when @@ -444,52 +471,53 @@ def test_softdeleted_get_response_skips_embedded_expansion(self): """ # Confirm embedded document works before delete _db = self.connection[MONGO_DBNAME] - fake_contact = self.random_contacts(1) - fake_contact_id = _db.contacts.insert(fake_contact)[0] - _db.invoices.update({'_id': ObjectId(self.invoice_id)}, - {'$set': {'person': fake_contact_id}}) - - invoices = self.domain['invoices'] - invoices['embedding'] = True - invoices['schema']['person']['data_relation']['embeddable'] = True + fake_contact = self.random_contacts(1)[0] + fake_contact_id = _db.contacts.insert_one(fake_contact).inserted_id + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, {"$set": {"person": fake_contact_id}} + ) + + invoices = self.domain["invoices"] + invoices["embedding"] = True + invoices["schema"]["person"]["data_relation"]["embeddable"] = True embedded = '{"person": 1}' - r = self.test_client.get( - self.invoice_id_url + '?embedded=%s' % embedded) - invoice_etag = r.headers['ETag'] + r = self.test_client.get(self.invoice_id_url + "?embedded=%s" % embedded) + invoice_etag = r.headers["ETag"] data, status = self.parse_response(r) self.assert200(status) - self.assertTrue('location' in data['person']) + self.assertTrue("location" in data["person"]) # Soft delete document data, status = self.delete( - self.invoice_id_url, headers=[('If-Match', invoice_etag)]) + self.invoice_id_url, headers=[("If-Match", invoice_etag)] + ) self.assert204(status) # Document in 404 should not expand person - r = self.test_client.get( - self.invoice_id_url + '?embedded=%s' % embedded) + r = self.test_client.get(self.invoice_id_url + "?embedded=%s" % embedded) data, status = self.parse_response(r) self.assert404(status) - self.assertEqual(data['person'], str(fake_contact_id)) + self.assertEqual(data["person"], str(fake_contact_id)) def test_softdelete_caching(self): - """404 Not Found responses after soft delete should be cacheable - """ + """404 Not Found responses after soft delete should be cacheable""" # Soft delete item r, status = self.delete(self.item_id_url, headers=self.etag_headers) self.assert204(status) # delete should have invalidated any previously cached 200 responses r = self.test_client.get( - self.item_id_url, headers=[('If-None-Match', self.item_etag)]) + self.item_id_url, headers=[("If-None-Match", self.item_etag)] + ) self.assert404(r.status_code) - post_delete_etag = r.headers['ETag'] + post_delete_etag = r.headers["ETag"] # validate cached 404 response data r = status = self.test_client.get( - self.item_id_url, headers=[('If-None-Match', post_delete_etag)]) + self.item_id_url, headers=[("If-None-Match", post_delete_etag)] + ) self.assert304(r.status_code) def test_softdelete_datalayer(self): @@ -505,25 +533,25 @@ def test_softdelete_datalayer(self): # find_one should only return item if a request w/ show_deleted == # True is passed or if the deleted field is part of the lookup req = ParsedRequest() - doc = self.app.data.find_one( - self.known_resource, req, _id=self.item_id) + doc = self.app.data.find_one(self.known_resource, req, _id=self.item_id) self.assertEqual(doc, None) req.show_deleted = True - doc = self.app.data.find_one( - self.known_resource, req, _id=self.item_id) + doc = self.app.data.find_one(self.known_resource, req, _id=self.item_id) self.assertNotEqual(doc, None) self.assertEqual(doc.get(self.deleted_field), True) req.show_deleted = False doc = self.app.data.find_one( - self.known_resource, req, _id=self.item_id, _deleted=True) + self.known_resource, req, _id=self.item_id, _deleted=True + ) self.assertNotEqual(doc, None) self.assertEqual(doc.get(self.deleted_field), True) # find_one_raw should always return a document, soft deleted or not doc = self.app.data.find_one_raw( - self.known_resource, _id=ObjectId(self.item_id)) + self.known_resource, _id=ObjectId(self.item_id) + ) self.assertNotEqual(doc, None) self.assertEqual(doc.get(self.deleted_field), True) @@ -531,134 +559,131 @@ def test_softdelete_datalayer(self): # show_deleted == True is passed or if the deleted field is part of # the lookup req.show_deleted = False - docs = self.app.data.find(self.known_resource, req, None) - undeleted_count = docs.count() + _, undeleted_count = self.app.data.find(self.known_resource, req, None) req.show_deleted = True - docs = self.app.data.find(self.known_resource, req, None) - with_deleted_count = docs.count() - self.assertEqual(undeleted_count, with_deleted_count - 1) + _, challenge = self.app.data.find(self.known_resource, req, None) + self.assertEqual(undeleted_count, challenge - 1) req.show_deleted = False - docs = self.app.data.find( - self.known_resource, req, {self.deleted_field: True}) - deleted_count = docs.count() + _, deleted_count = self.app.data.find( + self.known_resource, req, {self.deleted_field: True} + ) self.assertEqual(deleted_count, 1) # find_list_of_ids will return deleted documents if given their id - docs = self.app.data.find_list_of_ids( - self.known_resource, [ObjectId(self.item_id)]) - self.assertEqual(docs.count(), 1) + ids = self.app.data.find_list_of_ids( + self.known_resource, [ObjectId(self.item_id)] + ) + + self.assertEqual(str(ids[0]["_id"]), self.item_id) def test_softdelete_db_fields(self): """Documents created when soft delete is enabled should include and maintain the DELETED field in the db. """ - r = self.test_client.post(self.known_resource_url, data={ - 'ref': "1234567890123456789054321" - }) + r = self.test_client.post( + self.known_resource_url, data={"ref": "1234567890123456789054321"} + ) data, status = self.parse_response(r) self.assert201(status) - new_item_id = data[self.domain[self.known_resource]['id_field']] - new_item_etag = data[self.app.config['ETAG']] + new_item_id = data[self.domain[self.known_resource]["id_field"]] + new_item_etag = data[self.app.config["ETAG"]] with self.app.test_request_context(): db_stored_doc = self.app.data.find_one_raw( - self.known_resource, _id=ObjectId(new_item_id)) + self.known_resource, _id=ObjectId(new_item_id) + ) self.assertTrue(self.deleted_field in db_stored_doc) # PUT updates to the document should maintain the DELETED field r = self.test_client.put( self.known_resource_url + "/" + new_item_id, - data={'ref': '5432109876543210987654321'}, - headers=[('If-Match', new_item_etag)] + data={"ref": "5432109876543210987654321"}, + headers=[("If-Match", new_item_etag)], ) data, status = self.parse_response(r) self.assert200(status) - new_item_etag = data[self.app.config['ETAG']] + new_item_etag = data[self.app.config["ETAG"]] with self.app.test_request_context(): db_stored_doc = self.app.data.find_one_raw( - self.known_resource, _id=ObjectId(new_item_id)) + self.known_resource, _id=ObjectId(new_item_id) + ) self.assertTrue(self.deleted_field in db_stored_doc) # PATCH updates to the document should maintain the DELETED field r = self.test_client.patch( self.known_resource_url + "/" + new_item_id, - data={'ref': '5555544444333332222211111'}, - headers=[('If-Match', new_item_etag)] + data={"ref": "5555544444333332222211111"}, + headers=[("If-Match", new_item_etag)], ) self.assert200(r.status_code) with self.app.test_request_context(): db_stored_doc = self.app.data.find_one_raw( - self.known_resource, _id=ObjectId(new_item_id)) + self.known_resource, _id=ObjectId(new_item_id) + ) self.assertTrue(self.deleted_field in db_stored_doc) def test_exclusive_projection(self): - """ Test that when an exclusive projection is used in the 'datasource' + """Test that when an exclusive projection is used in the 'datasource' setting for the resource, enabling soft_deletes does not cause a 500 error. See #752. """ - r = self.test_client.get('/exclusion?show_deleted') + r = self.test_client.get("/exclusion?show_deleted") data, status = self.parse_response(r) self.assert200(status) def test_exclude_soft_deleted_documents_from_unique_checks(self): - """ Test that soft deleted documents are ignored when validating new + """Test that soft deleted documents are ignored when validating new documents against the 'unique' rule. See #831. """ unique_value = "1234567890123456789054321" # 'ref' field has a 'unique' rule applied to it. - r = self.test_client.post(self.known_resource_url, data={ - 'ref': unique_value - }) + r = self.test_client.post(self.known_resource_url, data={"ref": unique_value}) data, status = self.parse_response(r) self.assert201(status) - new_item_id = data[self.domain[self.known_resource]['id_field']] - new_item_etag = data[self.app.config['ETAG']] + new_item_id = data[self.domain[self.known_resource]["id_field"]] + new_item_etag = data[self.app.config["ETAG"]] # we can't post a new document with the same value. - r = self.test_client.post(self.known_resource_url, data={ - 'ref': unique_value - }) + r = self.test_client.post(self.known_resource_url, data={"ref": unique_value}) data, status = self.parse_response(r) self.assert422(status) # we now soft delete the document. r = self.test_client.delete( self.known_resource_url + "/" + new_item_id, - headers=[('If-Match', new_item_etag)] + headers=[("If-Match", new_item_etag)], ) data, status = self.parse_response(r) self.assert204(status) # posting a new document with the same value for 'ref' # is now possible. - r = self.test_client.post(self.known_resource_url, data={ - 'ref': unique_value - }) + r = self.test_client.post(self.known_resource_url, data={"ref": unique_value}) data, status = self.parse_response(r) self.assert201(status) class TestResourceSpecificSoftDelete(TestBase): def setUp(self): - super(TestResourceSpecificSoftDelete, self).setUp() + super().setUp() # Enable soft delete for one resource domain = copy.copy(self.domain) resource_settings = domain[self.known_resource] - resource_settings['soft_delete'] = True + resource_settings["soft_delete"] = True self.app.register_resource(self.known_resource, resource_settings) - self.deleted_field = self.app.config['DELETED'] + self.deleted_field = self.app.config["DELETED"] # Etag used to delete an item (a contact) - self.etag_headers = [('If-Match', self.item_etag)] + self.etag_headers = [("If-Match", self.item_etag)] def test_resource_specific_softdelete(self): - """ Resource level soft delete configuration should override + """Resource level soft delete configuration should override application configuration. """ # Confirm soft delete is enabled for known resource. @@ -672,7 +697,8 @@ def test_resource_specific_softdelete(self): # DELETE on other resources should be hard deletes data, status = self.delete( - self.invoice_id_url, headers=[('If-Match', self.invoice_etag)]) + self.invoice_id_url, headers=[("If-Match", self.invoice_etag)] + ) self.assert204(status) r = self.test_client.get(self.invoice_id_url) @@ -686,7 +712,7 @@ def test_on_pre_DELETE_for_item(self): devent = DummyEvent(self.before_delete) self.app.on_pre_DELETE += devent self.delete_item() - self.assertEqual('contacts', devent.called[0]) + self.assertEqual("contacts", devent.called[0]) self.assertFalse(devent.called[1] is None) def test_on_pre_DELETE_resource_for_item(self): @@ -710,10 +736,11 @@ def test_on_pre_DELETE_resource_for_resource(self): def test_on_pre_DELETE_dynamic_filter(self): def filter_this(resource, request, lookup): lookup["_id"] = self.unknown_item_id + self.app.on_pre_DELETE += filter_this - # Would normally delete the known document; will return 404 instead. + # Would normally delete the known document; will return 204 instead. r, s = self.parse_response(self.delete_item()) - self.assert404(s) + self.assert204(s) def test_on_post_DELETE_for_item(self): devent = DummyEvent(self.after_delete) @@ -740,22 +767,22 @@ def test_on_post_DELETE_resource_for_resource(self): self.assertFalse(devent.called is None) def test_on_delete_resource(self): - devent = DummyEvent(self.before_delete) - self.app.on_delete_resource += devent + devent1 = DummyEvent(self.before_delete) + self.app.on_delete_resource += devent1 + devent2 = DummyEvent(self.before_delete) + self.app.on_delete_resource_originals += devent2 self.delete_resource() - self.assertEqual(('contacts',), devent.called) + self.assertEqual(("contacts",), devent1.called) + self.assertFalse(devent2.called is None) def test_on_delete_resource_contacts(self): - devent = DummyEvent(self.before_delete) - self.app.on_delete_resource_contacts += devent + devent1 = DummyEvent(self.before_delete) + self.app.on_delete_resource_contacts += devent1 + devent2 = DummyEvent(self.before_delete) + self.app.on_delete_resource_originals_contacts += devent2 self.delete_resource() - self.assertEqual(tuple(), devent.called) - - def test_on_deleted_resource(self): - devent = DummyEvent(self.after_delete) - self.app.on_deleted_resource += devent - self.delete_resource() - self.assertEqual(('contacts',), devent.called) + self.assertEqual(tuple(), devent1.called) + self.assertFalse(devent2.called is None) def test_on_deleted_resource_contacts(self): devent = DummyEvent(self.after_delete) @@ -767,30 +794,30 @@ def test_on_delete_item(self): devent = DummyEvent(self.before_delete) self.app.on_delete_item += devent self.delete_item() - self.assertEqual('contacts', devent.called[0]) - id_field = self.domain['contacts']['id_field'] + self.assertEqual("contacts", devent.called[0]) + id_field = self.domain["contacts"]["id_field"] self.assertEqual(self.item_id, str(devent.called[1][id_field])) def test_on_delete_item_contacts(self): devent = DummyEvent(self.before_delete) self.app.on_delete_item_contacts += devent self.delete_item() - id_field = self.domain['contacts']['id_field'] + id_field = self.domain["contacts"]["id_field"] self.assertEqual(self.item_id, str(devent.called[0][id_field])) def test_on_deleted_item(self): devent = DummyEvent(self.after_delete) self.app.on_deleted_item += devent self.delete_item() - self.assertEqual('contacts', devent.called[0]) - id_field = self.domain['contacts']['id_field'] + self.assertEqual("contacts", devent.called[0]) + id_field = self.domain["contacts"]["id_field"] self.assertEqual(self.item_id, str(devent.called[1][id_field])) def test_on_deleted_item_contacts(self): devent = DummyEvent(self.after_delete) self.app.on_deleted_item_contacts += devent self.delete_item() - id_field = self.domain['contacts']['id_field'] + id_field = self.domain["contacts"]["id_field"] self.assertEqual(self.item_id, str(devent.called[0][id_field])) def delete_resource(self): @@ -798,7 +825,8 @@ def delete_resource(self): def delete_item(self): return self.test_client.delete( - self.item_id_url, headers=[('If-Match', self.item_etag)]) + self.item_id_url, headers=[("If-Match", self.item_etag)] + ) def before_delete(self): db = self.connection[MONGO_DBNAME] diff --git a/tests/methods/get.py b/tests/methods/get.py new file mode 100644 index 000000000..b3ff26e90 --- /dev/null +++ b/tests/methods/get.py @@ -0,0 +1,2444 @@ +import base64 +import time +from datetime import datetime, timedelta +from io import BytesIO + +import simplejson as json +from bson import ObjectId +from bson.dbref import DBRef +from bson.son import SON +from werkzeug.datastructures import ImmutableMultiDict, MultiDict + +from eve.methods.get import get_internal, getitem_internal +from eve.utils import date_to_rfc1123, str_to_date +from tests import TestBase +from tests.test_settings import MONGO_DBNAME +from tests.utils import DummyEvent + + +class TestGet(TestBase): + def test_get_empty_resource(self): + response, status = self.get(self.empty_resource) + self.assert200(status) + + resource = response["_items"] + self.assertEqual(len(resource), 0) + + links = response["_links"] + self.assertEqual(len(links), 2) + self.assertResourceLink(links, self.empty_resource) + self.assertHomeLink(links) + + self.assertPagination(response, 1, 0, 25) + + def test_get_max_results(self): + maxr = 10 + response, status = self.get(self.known_resource, "?max_results=%d" % maxr) + self.assert200(status) + + resource = response["_items"] + self.assertEqual(len(resource), maxr) + + maxr = self.app.config["PAGINATION_LIMIT"] + 1 + response, status = self.get(self.known_resource, "?max_results=%d" % maxr) + self.assert200(status) + resource = response["_items"] + self.assertEqual(len(resource), self.app.config["PAGINATION_LIMIT"]) + + def test_get_max_results_overridden(self): + # Generate 50 contacts. + self.random_contacts(num=50) + + # Set the max pagination limit to 7. + self.app.config["DOMAIN"][self.known_resource]["pagination_limit"] = 7 + + # Attempt to get all 50 contacts in one request. + response, status = self.get(self.known_resource, "?max_results=50") + self.assert200(status) + + # Validate that the response only contains 10 contacts. + resource = response["_items"] + self.assertEqual(len(resource), 7) + + def test_get_custom_max_results(self): + self.app.config["QUERY_MAX_RESULTS"] = "size" + maxr = 10 + response, status = self.get(self.known_resource, "?size=%d" % maxr) + self.assert200(status) + resource = response["_items"] + self.assertEqual(len(resource), maxr) + + def test_get_custom_params(self): + page = 2 + custom_params = MultiDict([("my_param", "value1"), ("my_param", "value2")]) + custom_query = "&".join( + "%s=%s" % (param, value) + for param, values in custom_params.lists() + for value in values + ) + response, status = self.get( + self.known_resource, "?%s&page=%d" % (custom_query, page) + ) + self.assert200(status) + + links = response["_links"] + self.assertCustomParams(links["prev"], custom_params) + self.assertCustomParams(links["next"], custom_params) + self.assertCustomParams(links["self"], custom_params) + self.assertCustomParams(links["last"], custom_params) + + def test_get_page(self): + response, status = self.get(self.known_resource) + self.assert200(status) + self.assertPage(response, status) + + def test_get_perform_count_on_pagination_disabled(self): + self.app.config["DOMAIN"][self.known_resource]["optimize_pagination_for_speed"] = True + + r = self.test_client.get("%s?page=2" % self.known_resource_url) + self.assert200(r.status_code) + + body = json.loads(r.get_data()) + links = body["_links"] + self.assertFalse("last" in links) + self.assertFalse("total" in body["_meta"]) + self.assertNextLink(links, 3) + self.assertPrevLink(links, 1) + self.assertFalse(self.app.config["HEADER_TOTAL_COUNT"] in r.headers) + + def test_get_internal_page(self): + with self.app.test_request_context(self.known_resource_url): + response, _, _, status, _ = get_internal(self.known_resource) + self.assertPage(response, status) + + def assertPage(self, response, status): + links = response["_links"] + self.assertNextLink(links, 2) + self.assertLastLink(links, 5) + self.assertPagination(response, 1, 101, 25) + + page = 1 + response, status = self.get(self.known_resource, "?page=%d" % page) + self.assert200(status) + + links = response["_links"] + self.assertNextLink(links, 2) + self.assertLastLink(links, 5) + self.assertPagination(response, 1, 101, 25) + + page = 2 + response, status = self.get(self.known_resource, "?page=%d" % page) + self.assert200(status) + + links = response["_links"] + self.assertNextLink(links, 3) + self.assertPrevLink(links, 1) + self.assertLastLink(links, 5) + self.assertPagination(response, 2, 101, 25) + + page = 5 + response, status = self.get(self.known_resource, "?page=%d" % page) + self.assert200(status) + + links = response["_links"] + self.assertPrevLink(links, 4) + self.assertLastLink(links, None) + self.assertPagination(response, 5, 101, 25) + + def test_get_custom_page(self): + self.app.config["QUERY_PAGE"] = "custom" + + page = 2 + response, status = self.get(self.known_resource, "?custom=%d" % page) + self.assert200(status) + + links = response["_links"] + self.assertNextLink(links, 3) + self.assertPrevLink(links, 1) + self.assertLastLink(links, 5) + self.assertPagination(response, 2, 101, 25) + + def test_get_pagination_no_documents(self): + """test that pagination meta is present even when no records are being + returned. #415. + """ + response, status = self.get(self.known_resource, '?where={"ref": "not_really"}') + self.assert200(status) + self.assertPagination(response, 1, 0, 25) + + def test_get_paging_disabled_no_args(self): + self.app.config["DOMAIN"][self.known_resource]["pagination"] = False + response, status = self.get(self.known_resource) + self.assert200(status) + resource = response["_items"] + self.assertEqual(len(resource), self.known_resource_count) + self.assertTrue(self.app.config["META"] not in response) + links = response["_links"] + self.assertTrue("next" not in links) + self.assertTrue("prev" not in links) + + def test_get_total_count_header(self): + url = self.domain[self.known_resource]["url"] + r = self.test_client.head(url) + response, status = self.parse_response(r) + self.assert200(status) + self.assertEqual(response, None) + + total_count = r.headers[self.app.config["HEADER_TOTAL_COUNT"]] + self.assertEqual(int(total_count), self.known_resource_count) + + def test_get_total_count_header_on_empty_resource(self): + url = self.domain[self.empty_resource]["url"] + r = self.test_client.head(url) + response, status = self.parse_response(r) + self.assert200(status) + self.assertEqual(response, None) + + self.assertIn(self.app.config["HEADER_TOTAL_COUNT"], r.headers) + total_count = r.headers[self.app.config["HEADER_TOTAL_COUNT"]] + self.assertEqual(int(total_count), 0) + + def test_get_where_mongo_syntax(self): + where = '{"ref": "%s"}' % self.item_name + response, status = self.get(self.known_resource, "?where=%s" % where) + self.assert200(status) + + resource = response["_items"] + self.assertEqual(len(resource), 1) + + def test_get_where_mongo_combined_date(self): + where = ( + '{"$and": [{"ref": "%s"}, {"_created": \ + {"$gte": "Tue, 01 Oct 2013 00:59:22 GMT"}}]}' + % self.item_name + ) + response, status = self.get(self.known_resource, "?where=%s" % where) + self.assert200(status) + + resource = response["_items"] + self.assertEqual(len(resource), 1) + + def test_get_custom_where(self): + self.app.config["QUERY_WHERE"] = "whereas" + where = '{"ref": "%s"}' % self.item_name + response, status = self.get(self.known_resource, "?whereas=%s" % where) + self.assert200(status) + + resource = response["_items"] + self.assertEqual(len(resource), 1) + + def test_get_mongo_query_blacklist(self): + where = '{"$where": "this.ref == ' "%s" '"}' % self.item_name + _, status = self.get(self.known_resource, "?where=%s" % where) + self.assert400(status) + + where = '{"ref": {"$regex": "%s"}}' % self.item_name + _, status = self.get(self.known_resource, "?where=%s" % where) + self.assert400(status) + + def test_get_mongo_query_blacklist_nested(self): + where = '{"$or": [{"$where": "this.ref == ' "%s" '"}]}' % self.item_name + _, status = self.get(self.known_resource, "?where=%s" % where) + self.assert400(status) + + where = '{"$or": [{"ref": {"$regex": "%s"}}]}' % self.item_name + _, status = self.get(self.known_resource, "?where=%s" % where) + self.assert400(status) + + def test_get_mongo_query_whitelist(self): + where = '{"$expr": {"$eq": [{"$year": "$_created"}, 2020]}}' + _, status = self.get(self.known_resource, "?where=%s" % where) + self.assert400(status) + + settings = self.app.config["DOMAIN"][self.known_resource] + settings["mongo_query_whitelist"] = ["$year"] + _, status = self.get(self.known_resource, "?where=%s" % where) + self.assert200(status) + + def test_get_mongo_query_whitelist_nested(self): + where = '{"$or": [{"$expr": {"$eq": [{"$year": "$_created"}, 2020]}}]}' + _, status = self.get(self.known_resource, "?where=%s" % where) + self.assert400(status) + + settings = self.app.config["DOMAIN"][self.known_resource] + settings["mongo_query_whitelist"] = ["$year"] + _, status = self.get(self.known_resource, "?where=%s" % where) + self.assert200(status) + + def test_get_where_mongo_objectid_as_string(self): + where = '{"tid": "%s"}' % self.item_tid + response, status = self.get(self.known_resource, "?where=%s" % where) + self.assert200(status) + resource = response["_items"] + self.assertEqual(len(resource), 1) + + self.app.config["DOMAIN"]["contacts"]["query_objectid_as_string"] = True + response, status = self.get(self.known_resource, "?where=%s" % where) + self.assert200(status) + resource = response["_items"] + self.assertEqual(len(resource), 1) + + def test_get_where_mongo_objectid_as_string_with_nested_documents(self): + where = '{"tid": { "$in": ["%s"]}}' % self.item_tid + response, status = self.get(self.known_resource, "?where=%s" % where) + self.assert200(status) + resource = response["_items"] + self.assertEqual(len(resource), 1) + + self.app.config["DOMAIN"]["contacts"]["query_objectid_as_string"] = True + response, status = self.get(self.known_resource, "?where=%s" % where) + self.assert200(status) + resource = response["_items"] + self.assertEqual(len(resource), 1) + + def test_get_where_mongo_objectid_as_string_but_field_is_id(self): + skus = self.to_list_string([item["sku"] for item in self.item_rows]) + where_in = '{"tid": "%s", "rows.sku": { "$in": %s} }' % (self.item_tid, skus) + response, status = self.get(self.known_resource, "?where=%s" % where_in) + self.assert200(status) + resource = response["_items"] + self.assertEqual(len(resource), 0) + + self.app.config["DOMAIN"]["contacts"]["query_objectid_as_string"] = True + response, status = self.get(self.known_resource, "?where=%s" % where_in) + self.assert200(status) + resource = response["_items"] + self.assertEqual(len(resource), 1) + + def test_get_where_python_syntax(self): + where = "ref == %s" % self.item_name + response, status = self.get(self.known_resource, "?where=%s" % where) + self.assert200(status) + + resource = response["_items"] + self.assertEqual(len(resource), 1) + + def test_get_where_python_syntax1(self): + where = ( + 'ref == %s and _created>="Tue, 01 Oct 2013 00:59:22 GMT"' % self.item_name + ) + response, status = self.get(self.known_resource, "?where=%s" % where) + self.assert200(status) + + resource = response["_items"] + self.assertEqual(len(resource), 1) + + def test_get_query_in_links(self): + """Make sure that query strings appear in all HATEOAS links (#464).""" + # find a role with enough results + for role in ("agent", "client", "vendor"): + where = "role == %s" % role + response, _ = self.get(self.known_resource, "?where=%s" % where) + if response["_meta"]["total"] >= self.app.config["PAGINATION_DEFAULT"] + 1: + break + links = response["_links"] + total = response["_meta"]["total"] + max_results = response["_meta"]["max_results"] + last_page = total / max_results + (1 if total % max_results else 0) + self.assertTrue("?where=%s" % where in links["self"]["href"]) + self.assertTrue("?where=%s" % where in links["next"]["href"]) + self.assertTrue("?where=%s" % where in links["last"]["href"]) + self.assertNextLink(links, 2) + self.assertLastLink(links, last_page) + + page = 2 + response, _ = self.get(self.known_resource, "?where=%s&page=%d" % (where, page)) + links = response["_links"] + self.assertTrue("?where=%s" % where in links["prev"]["href"]) + self.assertPrevLink(links, 1) + + def test_get_projection_consistent_etag(self): + """Test that #369 is fixed and projection queries return consistent + etags (as they are now stored along with the document). + """ + etag_field = self.app.config["ETAG"] + data = {"inv_number": self.random_string(10)} + + # post a new item so etag storage kicks in + r, status = self.post(self.empty_resource_url, data=data) + etag = r[etag_field] + + # hit the resource endpoint with a projection query + projection = '{"prog": 1}' + r, status = self.get(self.empty_resource, "?projection=%s" % projection) + # compare original etag with retrieved one + self.assertEqual(etag, r["_items"][0][etag_field]) + + def test_get_projection(self): + projection = '{"prog": 1}' + response, status = self.get(self.known_resource, "?projection=%s" % projection) + self.assert200(status) + + resource = response["_items"] + + for r in resource: + self.assertFalse("location" in r) + self.assertFalse("role" in r) + self.assertTrue("prog" in r) + self.assertTrue(self.domain[self.known_resource]["id_field"] in r) + self.assertTrue(self.app.config["ETAG"] in r) + self.assertTrue(self.app.config["LAST_UPDATED"] in r) + self.assertTrue(self.app.config["DATE_CREATED"] in r) + self.assertTrue(r[self.app.config["LAST_UPDATED"]] != self.epoch) + self.assertTrue(r[self.app.config["DATE_CREATED"]] != self.epoch) + + projection = '{"prog": 0}' + response, status = self.get(self.known_resource, "?projection=%s" % projection) + self.assert200(status) + + resource = response["_items"] + + for r in resource: + self.assertFalse("prog" in r) + self.assertTrue("location" in r) + self.assertTrue("role" in r) + self.assertTrue(self.domain[self.known_resource]["id_field"] in r) + self.assertTrue(self.app.config["ETAG"] in r) + self.assertTrue(self.app.config["LAST_UPDATED"] in r) + self.assertTrue(self.app.config["DATE_CREATED"] in r) + self.assertTrue(r[self.app.config["LAST_UPDATED"]] != self.epoch) + self.assertTrue(r[self.app.config["DATE_CREATED"]] != self.epoch) + + def test_get_static_projection(self): + """Test that static projections are honoured""" + response, status = self.get(self.different_resource) + self.assert200(status) + + resource = response["_items"] + + # 'users' has a static inclusive projection with 'username' and 'ref' + # fields, so other document fields should be excluded. + for r in resource: + self.assertFalse("location" in r) + self.assertFalse("role" in r) + self.assertFalse("prog" in r) + self.assertTrue("username" in r) + self.assertTrue("ref" in r) + self.assertTrue(self.domain[self.known_resource]["id_field"] in r) + self.assertTrue(self.app.config["ETAG"] in r) + self.assertTrue(self.app.config["LAST_UPDATED"] in r) + self.assertTrue(self.app.config["DATE_CREATED"] in r) + self.assertTrue(r[self.app.config["LAST_UPDATED"]] != self.epoch) + self.assertTrue(r[self.app.config["DATE_CREATED"]] != self.epoch) + + def test_get_server_include_projection_can_exclude(self): + """Test that static projection only expose fields included + and support client projection on these fields. + """ + # exclude `ref` by client side + projection = '{"ref": 0}' + response, status = self.get( + self.different_resource, "?projection=%s" % projection + ) + self.assert200(status) + + resource = response["_items"] + + # 'users' has a static inclusive projection with 'username' and 'ref' + # fields, so other document fields should be excluded. + # and client can further exclude 'ref' or 'username'. + for r in resource: + self.assertFalse("location" in r) + self.assertFalse("role" in r) + self.assertFalse("prog" in r) + self.assertTrue("username" in r) + self.assertFalse("ref" in r) + self.assertTrue(self.domain[self.known_resource]["id_field"] in r) + self.assertTrue(self.app.config["ETAG"] in r) + self.assertTrue(self.app.config["LAST_UPDATED"] in r) + self.assertTrue(self.app.config["DATE_CREATED"] in r) + self.assertTrue(r[self.app.config["LAST_UPDATED"]] != self.epoch) + self.assertTrue(r[self.app.config["DATE_CREATED"]] != self.epoch) + + def test_get_server_include_projection_block_sniff(self): + """Test that static projection only expose fields included + and client projection on other fields will fail. + """ + # shouldn't work when including `prog` (excluded) by client side + projection = '{"prog": 1}' + response, status = self.get( + self.different_resource, "?projection=%s" % projection + ) + self.assert200(status) + + resource = response["_items"] + for r in resource: + self.assertFalse("location" in r) + self.assertFalse("role" in r) + # shouldn't work + self.assertFalse("prog" in r) + self.assertFalse("username" in r) + self.assertFalse("ref" in r) + self.assertTrue(self.domain[self.known_resource]["id_field"] in r) + self.assertTrue(self.app.config["ETAG"] in r) + self.assertTrue(self.app.config["LAST_UPDATED"] in r) + self.assertTrue(self.app.config["DATE_CREATED"] in r) + self.assertTrue(r[self.app.config["LAST_UPDATED"]] != self.epoch) + self.assertTrue(r[self.app.config["DATE_CREATED"]] != self.epoch) + + def test_get_server_exclude_projection_can_project_others(self): + """Test that static projection expose fields other than excluded + and support client projection on exposed fields. + """ + projection = '{"prog": 1, "location":1}' + response, status = self.get( + self.different_resource_exclude, "?projection=%s" % projection + ) + self.assert200(status) + + resource = response["_items"] + + # 'users' has a static inclusive projection with 'username' and 'ref' + # fields, so other document fields should be excluded. + # and client can further exclude 'ref' or 'username'. + for r in resource: + self.assertTrue("location" in r) + self.assertFalse("role" in r) + self.assertTrue("prog" in r) + self.assertFalse("born" in r) + self.assertTrue(self.domain[self.known_resource]["id_field"] in r) + self.assertTrue(self.app.config["ETAG"] in r) + self.assertTrue(self.app.config["LAST_UPDATED"] in r) + self.assertTrue(self.app.config["DATE_CREATED"] in r) + self.assertTrue(r[self.app.config["LAST_UPDATED"]] != self.epoch) + self.assertTrue(r[self.app.config["DATE_CREATED"]] != self.epoch) + + def test_get_server_exlcude_projection_can_sniff(self): + """Test that static projection expose fields other than excluded + and client projection on excluded **will work**. + """ + projection = '{"born": 1}' + response, status = self.get( + self.different_resource_exclude, "?projection=%s" % projection + ) + self.assert200(status) + + resource = response["_items"] + for r in resource: + self.assertFalse("location" in r) + self.assertFalse("role" in r) + self.assertFalse("prog" in r) + # should work + self.assertTrue("born" in r) + self.assertTrue(self.domain[self.known_resource]["id_field"] in r) + self.assertTrue(self.app.config["ETAG"] in r) + self.assertTrue(self.app.config["LAST_UPDATED"] in r) + self.assertTrue(self.app.config["DATE_CREATED"] in r) + self.assertTrue(r[self.app.config["LAST_UPDATED"]] != self.epoch) + self.assertTrue(r[self.app.config["DATE_CREATED"]] != self.epoch) + + def test_get_custom_projection(self): + self.app.config["QUERY_PROJECTION"] = "view" + projection = '{"prog": 1}' + response, status = self.get(self.known_resource, "?view=%s" % projection) + self.assert200(status) + + resource = response["_items"] + + for r in resource: + self.assertFalse("location" in r) + self.assertFalse("role" in r) + self.assertTrue("prog" in r) + + def test_get_projection_subdocument(self): + projection = '{"location.address": 1}' + response, status = self.get(self.known_resource, "?projection=%s" % projection) + self.assert200(status) + + resource = response["_items"] + + for r in resource: + self.assertTrue("location" in r) + self.assertTrue("address" in r["location"]) + self.assertFalse("city" in r["location"]) + self.assertFalse("role" in r) + self.assertFalse("prog" in r) + self.assertTrue(self.domain[self.known_resource]["id_field"] in r) + self.assertTrue(self.app.config["ETAG"] in r) + self.assertTrue(self.app.config["LAST_UPDATED"] in r) + self.assertTrue(self.app.config["DATE_CREATED"] in r) + self.assertTrue(r[self.app.config["LAST_UPDATED"]] != self.epoch) + self.assertTrue(r[self.app.config["DATE_CREATED"]] != self.epoch) + + def test_get_projection_noschema(self): + self.app.config["DOMAIN"][self.known_resource]["schema"] = {} + response, status = self.get(self.known_resource) + self.assert200(status) + + resource = response["_items"] + + # fields are returned anyway since no schema = return all fields + for r in resource: + self.assertTrue("location" in r) + self.assertTrue(self.domain[self.known_resource]["id_field"] in r) + self.assertTrue(self.app.config["LAST_UPDATED"] in r) + self.assertTrue(self.app.config["DATE_CREATED"] in r) + + def test_get_where_disabled(self): + self.app.config["DOMAIN"][self.known_resource]["allowed_filters"] = [] + where = "ref == %s" % self.item_name + response, status = self.get(self.known_resource, "?where=%s" % where) + self.assert200(status) + resource = response["_items"] + self.assertEqual(len(resource), self.app.config["PAGINATION_DEFAULT"]) + + def test_get_sort_comma_delimited_syntax(self): + sort = "-prog" + response, status = self.get(self.known_resource, "?sort=%s" % sort) + self.assert200(status) + + resource = response["_items"] + self.assertEqual(len(resource), self.app.config["PAGINATION_DEFAULT"]) + topvalue = 100 + for i in range(len(resource)): + self.assertEqual(resource[i]["prog"], topvalue - i) + + def test_get_sort_mongo_syntax(self): + sort = '[("prog",-1)]' + response, status = self.get(self.known_resource, "?sort=%s" % sort) + self.assert200(status) + + resource = response["_items"] + self.assertEqual(len(resource), self.app.config["PAGINATION_DEFAULT"]) + topvalue = 100 + for i in range(len(resource)): + self.assertEqual(resource[i]["prog"], topvalue - i) + + def test_get_custom_sort(self): + self.app.config["QUERY_SORT"] = "orderby" + sort = '[("prog",-1)]' + response, status = self.get(self.known_resource, "?orderby=%s" % sort) + self.assert200(status) + + resource = response["_items"] + self.assertEqual(len(resource), self.app.config["PAGINATION_DEFAULT"]) + topvalue = 100 + for i in range(len(resource)): + self.assertEqual(resource[i]["prog"], topvalue - i) + + def test_get_sort_disabled(self): + self.app.config["DOMAIN"][self.known_resource]["sorting"] = False + sort = '[("prog",-1)]' + response, status = self.get(self.known_resource, "?sort=%s" % sort) + self.assert200(status) + resource = response["_items"] + self.assertEqual(len(resource), self.app.config["PAGINATION_DEFAULT"]) + + # this might actually fail on very rare occurences as mongodb + # 'natural' order is not granted to return documents in insertion order + self.assertEqual(resource[0]["prog"], 0) + + def test_get_default_sort(self): + s = self.app.config["DOMAIN"][self.known_resource]["datasource"] + + # set default sort to 'prog', desc. + s["default_sort"] = [("prog", -1)] + self.app.set_defaults() + response, _ = self.get(self.known_resource) + self.assertEqual(response["_items"][0]["prog"], 100) + + # set default sort to 'prog', asc. + s["default_sort"] = [("prog", 1)] + self.app.set_defaults() + response, _ = self.get(self.known_resource) + self.assertEqual(response["_items"][0]["prog"], 0) + + def test_cache_control(self): + self.assertCacheControl(self.known_resource_url) + + def test_expires(self): + self.assertExpires(self.known_resource_url) + + def test_get(self): + response, status = self.get(self.known_resource) + self.assertGet(response, status) + + def test_get_same_collection_different_resource(self): + """the 'users' resource is actually using the same db collection as + 'contacts'. Let's verify that base filters are being applied, and + the right amount of items/links and the correct titles etc. are being + returned. Of course 'contacts' itself has its own base filter, which + excludes the 'users' (those with a 'username' field). + """ + response, status = self.get(self.different_resource) + self.assert200(status) + + links = response["_links"] + self.assertEqual(len(links), 2) + self.assertHomeLink(links) + self.assertResourceLink(links, self.different_resource) + + resource = response["_items"] + self.assertEqual(len(resource), 2) + + for item in resource: + # 'user' title instead of original 'contact' + self.assertItem(item, self.different_resource) + + etag = item.get(self.app.config["ETAG"]) + self.assertTrue(etag is not None) + + def test_documents_missing_standard_date_fields(self): + """Documents created outside the API context could be lacking the + LAST_UPDATED and/or DATE_CREATED fields. + """ + contacts = self.random_contacts(1, False) + ref = "test_update_field" + contacts[0]["ref"] = ref + _db = self.connection[MONGO_DBNAME] + _db.contacts.insert_one(contacts[0]) + where = '{"ref": "%s"}' % ref + response, status = self.get(self.known_resource, "?where=%s" % where) + self.assert200(status) + resource = response["_items"] + self.assertEqual(len(resource), 1) + self.assertItem(resource[0], self.known_resource) + + def test_get_where_allowed_filters(self): + self.app.config["DOMAIN"][self.known_resource]["allowed_filters"] = [ + "notreally" + ] + where = '{"ref": "%s"}' % self.item_name + r = self.test_client.get( + "%s%s" % (self.known_resource_url, "?where=%s" % where) + ) + self.assert400(r.status_code) + self.assertTrue(b"'ref' not allowed" in r.get_data()) + + self.app.config["DOMAIN"][self.known_resource]["allowed_filters"] = ["*"] + r = self.test_client.get( + "%s%s" % (self.known_resource_url, "?where=%s" % where) + ) + self.assert200(r.status_code) + + # `allowed_filters` contains "rows" --> filter key "rows.price" + # must be allowed + self.app.config["DOMAIN"][self.known_resource]["allowed_filters"] = ["rows"] + where = '{"rows.price": 10}' + r = self.test_client.get( + "%s%s" % (self.known_resource_url, "?where=%s" % where) + ) + self.assert200(r.status_code) + + # `allowed_filters` contains "rows.price" --> filter key "rows.price" + # must be allowed + self.app.config["DOMAIN"][self.known_resource]["allowed_filters"] = [ + "rows.price" + ] + r = self.test_client.get( + "%s%s" % (self.known_resource_url, "?where=%s" % where) + ) + self.assert200(r.status_code) + + # `allowed_filters` contains "rows.price" --> filter key "rows" + # must NOT be allowed + where = '{"rows": {"sku": "value", "price": 10}}' + r = self.test_client.get( + "%s%s" % (self.known_resource_url, "?where=%s" % where) + ) + self.assert400(r.status_code) + self.assertTrue(b"'rows' not allowed" in r.get_data()) + + def test_get_with_post_override(self): + # POST request with GET override turns into a GET + headers = [("X-HTTP-Method-Override", "GET")] + r = self.test_client.post(self.known_resource_url, headers=headers) + response, status = self.parse_response(r) + self.assertGet(response, status) + + def test_get_custom_items(self): + self.app.config["ITEMS"] = "_documents" + response, _ = self.get(self.known_resource) + self.assertTrue("_documents" in response and "_items" not in response) + + def test_get_custom_links(self): + self.app.config["LINKS"] = "_navigation" + response, _ = self.get(self.known_resource) + self.assertTrue("_navigation" in response and "_links" not in response) + + def test_get_custom_hateoas_links(self): + def change_links(response): + response["_links"] = {"self": {"title": "Custom", "href": "/custom/1"}} + + self.app.on_fetched_resource_contacts += change_links + + response, _ = self.get(self.known_resource) + self.assertTrue("Custom" in response["_links"]["self"]["title"]) + self.assertTrue("/custom/1" in response["_links"]["self"]["href"]) + + def test_get_custom_auto_document_fields(self): + self.app.config["LAST_UPDATED"] = "_updated_on" + self.app.config["DATE_CREATED"] = "_created_on" + self.app.config["ETAG"] = "_the_etag" + response, _ = self.get(self.known_resource) + for document in response["_items"]: + self.assertTrue("_updated_on" in document) + self.assertTrue("_created_on" in document) + self.assertTrue("_the_etag" in document) + + def test_get_embedded_media_validate_rest_of_fields(self): + """test multipart/form-data resource fields that are JSON + encoded are validated correctly. #806 + """ + + self.app.config["MULTIPART_FORM_FIELDS_AS_JSON"] = True + resource_with_media = { + "image_file": {"type": "media"}, + "some_text": {"type": "string"}, + "some_boolean": {"type": "boolean"}, + "some_number": {"type": "number"}, + "some_list": {"type": "list", "schema": {"type": "string"}}, + } + self.app.register_resource("res_img", {"schema": resource_with_media}) + + img = b"some_image" + + # fail on boolean validate + data = {"image_file": (BytesIO(img), "test.txt"), "some_boolean": "123"} + response, status = self.parse_response( + self.test_client.post( + "res_img", data=data, headers=[("Content-Type", "multipart/form-data")] + ) + ) + self.assert422(status) + + # fail on number validattion + data = {"image_file": (BytesIO(img), "test.txt"), "some_number": "xyz"} + response, status = self.parse_response( + self.test_client.post( + "res_img", data=data, headers=[("Content-Type", "multipart/form-data")] + ) + ) + self.assert422(status) + + # fail on list validation + data = {"image_file": (BytesIO(img), "test.txt"), "some_list": "true"} + response, status = self.parse_response( + self.test_client.post( + "res_img", data=data, headers=[("Content-Type", "multipart/form-data")] + ) + ) + self.assert422(status) + + # validate all fields correctly + data = { + "image_file": (BytesIO(img), "test.txt"), + "some_text": '"abc"', + "some_boolean": "true", + "some_number": "123", + "some_list": '["abc", "xyz"]', + } + response, status = self.parse_response( + self.test_client.post( + "res_img", data=data, headers=[("Content-Type", "multipart/form-data")] + ) + ) + self.assert201(status) + self.app.config["MULTIPART_FORM_FIELDS_AS_JSON"] = False + + def test_get_embedded_media(self): + """test that embeedded images are properly rendered and #305 is fixed.""" + + # add a 'digital_assets' endpoint to the API + self.app.register_resource( + "digital_assets", {"schema": {"file": {"type": "media"}}} + ) + + # add an 'images' endpoint to the API. this will expose the embedded + # digital assets + images = { + "image_file": { + "type": "objectid", + "data_relation": { + "resource": "digital_assets", + "field": "_id", + "embeddable": True, + }, + } + } + self.app.register_resource("images", {"schema": images}) + + # post an asset + asset = b"a_file" + data = {"file": (BytesIO(asset), "test.txt")} + response, status = self.parse_response( + self.test_client.post( + "digital_assets", + data=data, + headers=[("Content-Type", "multipart/form-data")], + ) + ) + self.assert201(status) + + # post a document to the 'images' endpoint. the document is referencing + # the newly posted digital asset. + data = {"image_file": ObjectId(response["_id"])} + response, status = self.parse_response( + self.test_client.post("images", data=data) + ) + self.assert201(status) + + # retrieve the document from the same endpoint, requesting for the + # digital asset to be embedded within the retrieved document + image_id = response["_id"] + response, status = self.parse_response( + self.test_client.get( + "%s/%s%s" % ("images", image_id, '?embedded={"image_file": 1}') + ) + ) + self.assert200(status) + + # test that the embedded document contains the same data as orignially + # posted on the digital_asset endpoint. + returned = response["image_file"]["file"] + # encodedstring will raise a DeprecationWarning under Python3.3, but + # the alternative encodebytes is not available in Python 2. + encoded = base64.b64encode(asset).decode("utf-8") + self.assertEqual(returned, encoded) + self.assertEqual(base64.b64decode(returned.encode()), asset) + + def test_get_embedded(self): + # We need to assign a `person` to our test invoice + _db = self.connection[MONGO_DBNAME] + + fake_contact = self.random_contacts(1)[0] + fake_contact_id = _db.contacts.insert_one(fake_contact).inserted_id + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, {"$set": {"person": fake_contact_id}} + ) + + invoices = self.domain["invoices"] + + # Test that we get 400 if can't parse dict + embedded = "not-a-dict" + r = self.test_client.get("%s/%s" % (invoices["url"], "?embedded=%s" % embedded)) + self.assert400(r.status_code) + + # Test that doesn't come embedded if asking for a field that + # isn't embedded (global setting is False by default) + embedded = '{"person": 1}' + r = self.test_client.get("%s/%s" % (invoices["url"], "?embedded=%s" % embedded)) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertEqual(content["_items"][0]["person"], str(fake_contact_id)) + + # Set field to be embedded + invoices["schema"]["person"]["data_relation"]["embeddable"] = True + + # Test that global setting applies even if field is set to embedded + invoices["embedding"] = False + self.clearSchemaCache() + r = self.test_client.get("%s/%s" % (invoices["url"], "?embedded=%s" % embedded)) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertEqual(content["_items"][0]["person"], str(fake_contact_id)) + + # Test that it works + invoices["embedding"] = True + self.clearSchemaCache() + r = self.test_client.get("%s/%s" % (invoices["url"], "?embedded=%s" % embedded)) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertTrue("location" in content["_items"][0]["person"]) + + # Test that it ignores a bogus field + embedded = '{"person": 1, "not-a-real-field": 1}' + r = self.test_client.get("%s/%s" % (invoices["url"], "?embedded=%s" % embedded)) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertTrue("location" in content["_items"][0]["person"]) + + # Test that it ignores a real field with a bogus value + embedded = '{"person": 1, "inv_number": "not-a-real-value"}' + r = self.test_client.get("%s/%s" % (invoices["url"], "?embedded=%s" % embedded)) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertTrue("location" in content["_items"][0]["person"]) + + # Test that it works with item endpoint too + r = self.test_client.get( + "%s/%s/%s" % (invoices["url"], self.invoice_id, "?embedded=%s" % embedded) + ) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertTrue("location" in content["person"]) + + # Add new embeddable field to schema + invoices["schema"]["missing-field"] = { + "type": "objectid", + "data_relation": {"resource": "contacts", "embeddable": True}, + } + self.clearSchemaCache() + + # Test that it ignores embeddable field that is missing from document + embedded = '{"missing-field": 1}' + r = self.test_client.get("%s/%s" % (invoices["url"], "?embedded=%s" % embedded)) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertFalse("missing-field" in content["_items"][0]) + + # Test default fields to be embedded + invoices["embedded_fields"] = ["person"] + self.clearSchemaCache() + r = self.test_client.get("%s/" % invoices["url"]) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertTrue("location" in content["_items"][0]["person"]) + + # Test that default fields are overwritten by ?embedded=...0 + embedded = '{"person": 0}' + r = self.test_client.get("%s/%s" % (invoices["url"], "?embedded=%s" % embedded)) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertFalse("location" in content["_items"][0]["person"]) + + def test_get_custom_embedded(self): + self.app.config["QUERY_EMBEDDED"] = "included" + # We need to assign a `person` to our test invoice + _db = self.connection[MONGO_DBNAME] + + fake_contact = self.random_contacts(1)[0] + fake_contact_id = _db.contacts.insert_one(fake_contact).inserted_id + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, {"$set": {"person": fake_contact_id}} + ) + + invoices = self.domain["invoices"] + invoices["schema"]["person"]["data_relation"]["embeddable"] = True + + # Test that doesn't come embedded if asking for a field that + # isn't embedded (global setting is False by default) + embedded = '{"person": 1}' + invoices["embedding"] = True + r = self.test_client.get("%s/%s" % (invoices["url"], "?included=%s" % embedded)) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertTrue("location" in content["_items"][0]["person"]) + + def test_get_reference_embedded_in_subdocuments(self): + _db = self.connection[MONGO_DBNAME] + + holding_contacts = self.random_contacts(2) + holding_contact_ids = _db.contacts.insert_many(holding_contacts).inserted_ids + contacts = self.random_contacts(2) + contact_ids = _db.contacts.insert_many(contacts).inserted_ids + holding = { + "departments": [{"title": "managment", "members": holding_contact_ids}] + } + holding_id = _db.companies.insert_one(holding).inserted_id + company = { + "holding": holding_id, + "departments": [{"title": "development", "members": contact_ids}], + } + company_id = _db.companies.insert_one(company).inserted_id + # Add a documents with no reference that should be ignored + _db.companies.insert_one({}) + _db.companies.insert_one({"departments": []}) + + companies = self.domain["companies"] + contact_ids = list(map(str, contact_ids)) + + # Test that doesn't come embedded if asking for a field that + # isn't embedded ('embeddable' is False by default) + embedded = ( + '{"departments.members": 1,' + + ' "holding": 1, "holding.departments.members": 1}' + ) + r = self.test_client.get( + "%s/%s" % (companies["url"], "?embedded=%s" % embedded) + ) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertEqual(content["_items"][1]["departments"][0]["members"], contact_ids) + # Set field to be embedded + department_def = companies["schema"]["departments"]["schema"] + member_def = department_def["schema"]["members"]["schema"] + member_def["data_relation"]["embeddable"] = True + companies["schema"]["holding"]["data_relation"]["embeddable"] = True + + # Test that global setting applies even if field is set to embedded + companies["embedding"] = False + r = self.test_client.get( + "%s/%s" % (companies["url"], "?embedded=%s" % embedded) + ) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertEqual(content["_items"][1]["departments"][0]["members"], contact_ids) + + # Test that it works + companies["embedding"] = True + r = self.test_client.get( + "%s/%s" % (companies["url"], "?embedded=%s" % embedded) + ) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertTrue( + "location" in content["_items"][0]["departments"][0]["members"][0] + ) + # Test that the second company is associated with the holding + self.assertTrue( + "location" + in content["_items"][1]["holding"]["departments"][0]["members"][0] + ) + + # Test that it ignores a bogus field + embedded = '{"departments.members": 1, "not-a-real-field": 1}' + r = self.test_client.get( + "%s/%s" % (companies["url"], "?embedded=%s" % embedded) + ) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertTrue( + "location" in content["_items"][0]["departments"][0]["members"][0] + ) + + # Test that it works with item endpoint too + embedded = '{"departments.members": 1}' + r = self.test_client.get( + "%s/%s/%s" % (companies["url"], company_id, "?embedded=%s" % embedded) + ) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertTrue("location" in content["departments"][0]["members"][0]) + + # Test default fields to be embedded + companies["embedded_fields"] = ["departments.members"] + r = self.test_client.get("%s/" % companies["url"]) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertTrue( + "location" in content["_items"][0]["departments"][0]["members"][0] + ) + + # Test that default fields are overwritten by ?embedded=...0 + embedded = '{"departments.members": 0}' + r = self.test_client.get( + "%s/%s" % (companies["url"], "?embedded=%s" % embedded) + ) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertFalse( + "location" in content["_items"][0]["departments"][0]["members"][0] + ) + + def test_get_reference_embedded_in_subdocuments_with_nested_dicts(self): + _db = self.connection[MONGO_DBNAME] + cpu_brand_name = self.random_string(10) + cpu_brand = {"name": cpu_brand_name, "address": self.random_string(30)} + motherboard_brand_name = self.random_string(15) + motherboard_brand = { + "name": motherboard_brand_name, + "address": self.random_string(30), + } + cpu_brand_id, motherboard_brand_id = _db.brands.insert_many( + [cpu_brand, motherboard_brand] + ).inserted_ids + cpu_component = { + "name": self.random_string(12), + "price": 499, + "brand": cpu_brand_id, + } + motherboard_component = { + "name": self.random_string(18), + "price": 199, + "brand": motherboard_brand_id, + } + cpu_component_id, motherboard_component_id = _db.components.insert_many( + [cpu_component, motherboard_component] + ).inserted_ids + computer = { + "name": self.random_string(25), + "components": { + "cpu": cpu_component_id, + "motherboard": motherboard_component_id, + }, + } + computer_id = _db.computers.insert_one(computer).inserted_id + computers = self.domain["computers"] + components = self.domain["components"] + # Test that doesn't come embedded if asking for a field that + # isn't embedded ('embeddable' is False by default) + embedded = ( + '{"components.cpu": 1, "components.motherboard": 1,' + + ' "components.cpu.brand": 1, "components.motherboard.brand": 1}' + ) + result = self.test_client.get( + "%s/%s/%s" % (computers["url"], computer_id, "?embedded=%s" % embedded) + ) + self.assert200(result.status_code) + content = json.loads(result.get_data()) + self.assertEqual(content["components"]["cpu"], str(cpu_component_id)) + self.assertEqual( + content["components"]["motherboard"], str(motherboard_component_id) + ) + # Set field to be embedded + computers["schema"]["components"]["schema"]["cpu"]["data_relation"][ + "embeddable" + ] = True + computers["schema"]["components"]["schema"]["motherboard"]["data_relation"][ + "embeddable" + ] = True + components["schema"]["brand"]["data_relation"]["embeddable"] = True + # Test that global setting applies even if field is set to embedded + computers["embedding"] = False + components["embedding"] = False + result = self.test_client.get( + "%s/%s/%s" % (computers["url"], computer_id, "?embedded=%s" % embedded) + ) + self.assert200(result.status_code) + content = json.loads(result.get_data()) + self.assertEqual(content["components"]["cpu"], str(cpu_component_id)) + self.assertEqual( + content["components"]["motherboard"], str(motherboard_component_id) + ) + # Test that it works + computers["embedding"] = True + components["embedding"] = True + result = self.test_client.get( + "%s/%s/%s" % (computers["url"], computer_id, "?embedded=%s" % embedded) + ) + self.assert200(result.status_code) + content = json.loads(result.get_data()) + self.assertEqual(content["components"]["cpu"]["brand"]["name"], cpu_brand_name) + self.assertEqual( + content["components"]["motherboard"]["brand"]["name"], + motherboard_brand_name, + ) + + def test_get_nested_resource(self): + response, status = self.get("users/overseas") + self.assertGet(response, status, "users_overseas") + + def test_cursor_extra_find(self): + _find = self.app.data.find + hits = {"total_hits": 0} + + def find(resource, req, sub_resource_lookup, perform_count=True): + def extra(response): + response["_hits"] = hits + + cursor, _ = _find(resource, req, sub_resource_lookup) + cursor.extra = extra + return cursor, _ + + self.app.data.find = find + r, status = self.get(self.known_resource) + self.assert200(status) + self.assertTrue("_hits" in r) + self.assertEqual(r["_hits"], hits) + + def test_get_resource_title(self): + # test that resource endpoints accepts custom titles. + self.app.config["DOMAIN"][self.known_resource]["resource_title"] = "new title" + response, _ = self.get(self.known_resource) + self.assertTrue("new title" in response["_links"]["self"]["title"]) + # test that the home page accepts custom titles. + response, _ = self.get("/") + found = False + for link in response["_links"]["child"]: + if link["title"] == "new title": + found = True + break + self.assertTrue(found) + + def test_get_subresource(self): + _db = self.connection[MONGO_DBNAME] + + # create random contact + fake_contact = self.random_contacts(1)[0] + fake_contact_id = _db.contacts.insert_one(fake_contact).inserted_id + # update first invoice to reference the new contact + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, {"$set": {"person": fake_contact_id}} + ) + + # GET all invoices by new contact + response, status = self.get("users/%s/invoices" % fake_contact_id) + self.assert200(status) + # only 1 invoice + self.assertEqual(len(response["_items"]), 1) + self.assertEqual(len(response["_links"]), 2) + # which links to the right contact + self.assertEqual(response["_items"][0]["person"], str(fake_contact_id)) + + def test_get_ifmatch_disabled(self): + # when IF_MATCH is disabled no etag is present in payload + self.app.config["IF_MATCH"] = False + response, status = self.get(self.known_resource) + resource = response["_items"] + + for r in resource: + self.assertTrue(self.app.config["ETAG"] not in r) + + def test_get_ims_empty_resource(self): + # test that a GET with a If-Modified-Since on an empty resource does + # not trigger a 304 and returns a empty resource instead (#243). + + # get the resource and retrieve its IMS. + r = self.test_client.get(self.known_resource_url) + last_modified = r.headers.get("Last-Modified") + + # delete the whole resource content. + r = self.test_client.delete(self.known_resource_url) + + # send a get with a IMS header from previous GET. + r = self.test_client.get( + self.known_resource_url, headers=[("If-Modified-Since", last_modified)] + ) + self.assert200(r.status_code) + self.assertEqual(json.loads(r.get_data())["_items"], []) + + def test_get_idfield_doesnt_exist(self): + # test that a non-existing id field will be silently handled when + # building HATEOAS document link (#351). + self.domain[self.known_resource]["id_field"] = "id" + response, status = self.get(self.known_resource) + self.assert200(status) + + def test_get_invalid_idfield_cors(self): + """test that #381 is fixed.""" + request = "/%s/badid" % self.known_resource + self.app.config["X_DOMAINS"] = "*" + r = self.test_client.get(request, headers=[("Origin", "test.com")]) + self.assert404(r.status_code) + + def test_get_invalid_where_syntax(self): + """test that 'where' syntax with unknown '$' operator returns 400.""" + response, status = self.get( + self.known_resource, '?where={"field": {"$foo": "bar"}}' + ) + self.assert400(status) + + def test_get_invalid_sort_syntax(self): + """test that invalid sort syntax returns a 400""" + response, status = self.get(self.known_resource, '?sort=[("prog":1)]') + self.assert400(status) + response, status = self.get(self.known_resource, '?sort="firstname"') + self.assert400(status) + + def test_get_allowed_filters_operators(self): + """test that supported operators are not considered invalid filters + (#388). Also, test that nested filters are validated. + """ + where = '?where={"$and": [{"field1": "value1"}, {"field2": "value2"}]}' + settings = self.app.config["DOMAIN"][self.known_resource] + + # valid + settings["allowed_filters"] = ["field1", "field2"] + response, status = self.get(self.known_resource, where) + self.assert200(status) + + # invalid + settings["allowed_filters"] = ["field2"] + response, status = self.get(self.known_resource, where) + self.assert400(status) + + def test_get_nested_filter_operators_unvalidated(self): + """test that nested filter operators are working correctly.""" + where = "".join( + ( + '?where={"$and":[{"$or":[{"fldA":"valA"},', + '{"fldB":"valB"}]},{"fld2":"val2"}]}', + ) + ) + response, status = self.get(self.known_resource, where) + self.assert200(status) + + def test_get_nested_filter_operators_validated(self): + """test that nested filter operators are working correctly.""" + self.app.config["VALIDATE_FILTERS"] = True + + where = "".join( + ( + '?where={"$and":[{"$or":[{"fldA":"valA"},', + '{"fldB":"valB"}]},{"fld2":"val2"}]}', + ) + ) + response, status = self.get(self.known_resource, where) + self.assert400(status) + + where = "".join( + ( + '?where={"$and":[{"$or":[{"role":', + '["agent","client"]},{"key1":"str"}]}, {"prog":1}]}', + ) + ) + response, status = self.get(self.known_resource, where) + self.assert200(status) + + def test_get_invalid_where_fields(self): + """test that checks all fields of the where clause to be valid + resource fields according to the resource schema. + """ + self.app.config["VALIDATE_FILTERS"] = True + + # test for an outright missing/invalid field present + where = '?where={"$and": [{"bad_field": "val"}, {"fld2": "val2"}]}' + response, status = self.get(self.known_resource, where) + self.assert400(status) + + # test for resource field not validating correctly (prog is number) + where = '?where={"prog": "stringValue"}' + response, status = self.get(self.known_resource, where) + self.assert400(status) + + # test for resource field validating correctly (key1 is string) + where = '?where={"key1": "qwerty"}' + response, status = self.get(self.known_resource, where) + self.assert200(status) + + # test for nested resource field validating correctly + # (location is dict) + where = '?where={"location":{"address":"str 1","city":"SomeCity"}}' + response, status = self.get(self.known_resource, where) + self.assert200(status) + + # test for nested resource field validating correctly + # (location is dict) + where = '?where={"location.address": "str 1"}' + response, status = self.get(self.known_resource, where) + self.assert200(status) + + # test for nested resource field validating correctly + # (rows is list of dicts) + where = '?where={"rows.price": 10}' + response, status = self.get(self.known_resource, where) + self.assert200(status) + + # test for nested resource field validating correctly + # (dict_list_fixed_len is a fixed-size list of dicts) + where = '?where={"dict_list_fixed_len.key2": 1}' + response, status = self.get(self.known_resource, where) + self.assert200(status) + + # test for nested resource field not validating correctly + # (bad_base_key doesn't exist in the base resource schema) + where = '?where={"bad_base_key.sub": 1}' + response, status = self.get(self.known_resource, where) + self.assert400(status) + + # test for nested resource field not validating correctly + # (bad_sub_key doesn't exist in the dict_list_fixed_len schema) + where = '?where={"dict_list_fixed_len.bad_sub_key": 1}' + response, status = self.get(self.known_resource, where) + self.assert400(status) + + def test_get_lookup_field_as_string(self): + # Test that a resource where 'item_lookup_field' is set to a field + # of string type and which value is castable to a ObjectId is still + # treated as a string when 'query_objectid_as_string' is set to True. + # See PR #552. + self.app.config["DOMAIN"]["contacts"]["query_objectid_as_string"] = True + data = {"id": "507c7f79bcf86cd7994f6c0e", "name": "507c7f79bcf86cd7994f6c0e"} + response, status = self.post("ids", data=data) + self.assert201(status) + + where = '?where={"name": "507c7f79bcf86cd7994f6c0e"}' + response, status = self.get("ids", where) + self.assert200(status) + items = response["_items"] + self.assertEqual(1, len(items)) + + def test_get_custom_idfield(self): + response, status = self.get("products") + self.assert200(status) + links = response["_links"] + self.assertEqual(2, len(links)) + self.assertHomeLink(links) + self.assertResourceLink(links, "products") + items = response["_items"] + self.assertEqual(10, len(items)) + for item in items: + self.assertItem(item, "products") + + def test_get_subresource_with_custom_idfield(self): + db = self.connection[MONGO_DBNAME] + parent_product_sku = db.products.find_one()["sku"] + product = { + "sku": "BAZ", + "title": "Child product", + "parent_product": parent_product_sku, + } + db.products.insert_one(product) + response, status = self.get("products/%s/children" % parent_product_sku) + self.assert200(status) + self.assertEqual(len(response["_items"]), 1) + self.assertEqual(len(response["_links"]), 2) + self.assertEqual(response["_items"][0]["parent_product"], parent_product_sku) + + def test_get_aggregation_endpoint(self): + _db = self.connection[MONGO_DBNAME] + _db.aggregate_test.insert_many( + [ + {"x": 1, "tags": ["dog", "cat"]}, + {"x": 2, "tags": ["cat"]}, + {"x": 2, "tags": ["mouse", "cat", "dog"]}, + {"x": 3, "tags": []}, + ] + ) + + self.devent = DummyEvent(lambda: True) + self.app.before_aggregation += self.devent + + self.app.register_resource( + "aggregate_test", + { + "datasource": { + "aggregation": { + "pipeline": [ + {"$unwind": "$tags"}, + {"$group": {"_id": "$tags", "count": {"$sum": "$field1"}}}, + {"$sort": SON([("count", -1), ("_id", -1)])}, + ] + } + } + }, + ) + + response, status = self.get("aggregate_test?aggregate=ciao") + self.assert400(status) + self.assertTrue(self.devent.called is None) + + def assertOutput(doc, count, id): + self.assertEqual(doc["count"], count) + self.assertEqual(doc["_id"], id) + + response, status = self.get('aggregate_test?aggregate={"$field1":1}') + self.assert200(status) + docs = response["_items"] + self.assertEqual(len(docs), 3) + assertOutput(docs[0], 3, "cat") + assertOutput(docs[1], 2, "dog") + assertOutput(docs[2], 1, "mouse") + self.assertEqual("aggregate_test", self.devent.called[0]) + + response, status = self.get('aggregate_test?aggregate={"$field1":2}') + self.assert200(status) + docs = response["_items"] + self.assertEqual(len(docs), 3) + assertOutput(docs[0], 6, "cat") + assertOutput(docs[1], 4, "dog") + assertOutput(docs[2], 2, "mouse") + self.assertEqual("aggregate_test", self.devent.called[0]) + + # this will return 0 for all documents 'count' fields as no $field1 + # will be gien with the query (actually, no query will be there at all) + response, status = self.get("aggregate_test") + self.assert200(status) + docs = response["_items"] + self.assertEqual(len(docs), 3) + self.assertEqual(docs[0]["count"], 0) + self.assertEqual(docs[1]["count"], 0) + self.assertEqual(docs[2]["count"], 0) + self.assertEqual("aggregate_test", self.devent.called[0]) + + # malformed field name is ignored + response, status = self.get('aggregate_test?aggregate={"field1":1}') + self.assert200(status) + self.assertEqual("aggregate_test", self.devent.called[0]) + + # unknown field is ignored + response, status = self.get('aggregate_test?aggregate={"$unknown":1}') + self.assert200(status) + + # max_results is considered + response, status = self.get( + 'aggregate_test?aggregate={"$field1":1}&max_results=1' + ) + self.assert200(status) + docs = response["_items"] + self.assertEqual(len(docs), 1) + + def test_get_aggregation_parsing(self): + date = datetime.utcnow() + + _db = self.connection[MONGO_DBNAME] + _db.aggregate_test.insert_many( + [ + {"x": 1, "date": date}, + {"x": 2, "date": date}, + {"x": 3, "date": date}, + {"x": 4, "date": date + timedelta(days=-1)}, + ] + ) + + self.app.register_resource( + "aggregate_test", + { + "datasource": { + "aggregation": { + "pipeline": [{"$match": {"date": {"$gte": "$date"}}}] + } + } + }, + ) + + challenge = date.strftime(self.app.config["DATE_FORMAT"]) + response, status = self.get( + 'aggregate_test?aggregate={"$date": "%s"}' % challenge + ) + self.assert200(status) + docs = response["_items"] + self.assertEqual(len(docs), 3) + + challenge = (date + timedelta(days=-1)).strftime(self.app.config["DATE_FORMAT"]) + response, status = self.get( + 'aggregate_test?aggregate={"$date": "%s"}' % challenge + ) + self.assert200(status) + docs = response["_items"] + self.assertEqual(len(docs), 4) + + def test_get_aggregation_with_lists(self): + _db = self.connection[MONGO_DBNAME] + _db.aggregate_test.insert_many( + [ + {"x": 1, "tags": ["a", "b", "c"]}, + {"x": 2, "tags": ["a"]}, + {"x": 3, "tags": ["a", "b"]}, + {"x": [4], "tags": []}, + ] + ) + + self.app.register_resource( + "aggregate_test", + { + "datasource": { + "aggregation": { + "pipeline": [ + { + "$match": { + "$or": [{"tags": "$match_tags"}, {"x": ["$x"]}] + } + } + ] + } + } + }, + ) + + response, status = self.get('aggregate_test?aggregate={"$match_tags": "a"}') + self.assert200(status) + docs = response["_items"] + self.assertEqual(len(docs), 3) + + response, status = self.get( + 'aggregate_test?aggregate={"$match_tags": ["a", "b"]}' + ) + self.assert200(status) + docs = response["_items"] + self.assertEqual(len(docs), 1) + + response, status = self.get('aggregate_test?aggregate={"$x": 4}') + self.assert200(status) + docs = response["_items"] + self.assertEqual(len(docs), 1) + + def test_get_aggregation_pruning(self): + date = datetime.utcnow() + + _db = self.connection[MONGO_DBNAME] + _db.aggregate_test.insert_many( + [ + {"x": 1, "date": date}, + {"x": 2, "date": date}, + {"x": 3, "date": date}, + {"x": 4, "date": date + timedelta(days=-1)}, + ] + ) + + self.app.register_resource( + "aggregate_test", + { + "datasource": { + "aggregation": { + "pipeline": [{"$match": {"date": {"$gte": "$date"}, "x": "$x"}}] + } + } + }, + ) + + # look for date = now, x = 4, which shall return empty result + challenge = date.strftime(self.app.config["DATE_FORMAT"]) + response, status = self.get( + 'aggregate_test?aggregate={"$date": "%s", "$x": 4}' % challenge + ) + self.assert200(status) + docs = response["_items"] + self.assertEqual(len(docs), 0) + + # look for date = yesterday, x = 4, which shall return only one result + challenge = (date + timedelta(days=-1)).strftime(self.app.config["DATE_FORMAT"]) + response, status = self.get( + 'aggregate_test?aggregate={"$date": "%s", "$x": 4}' % challenge + ) + self.assert200(status) + docs = response["_items"] + self.assertEqual(len(docs), 1) + self.assertEqual(docs[0]["x"], 4) + + # look for date = yesterday, which shall return all four results + challenge = (date + timedelta(days=-1)).strftime(self.app.config["DATE_FORMAT"]) + response, status = self.get( + 'aggregate_test?aggregate={"$date": "%s", "$x": {}}' % challenge + ) + + self.assert200(status) + docs = response["_items"] + self.assertEqual(len(docs), 4) + + # look for x = 3, which shall return only one result + response, status = self.get('aggregate_test?aggregate={"$x": 3, "$date": {}}') + + self.assert200(status) + docs = response["_items"] + self.assertEqual(len(docs), 1) + self.assertEqual(docs[0]["x"], 3) + + def test_get_aggregation_pagination(self): + _db = self.connection[MONGO_DBNAME] + + num = 75 + _db.aggregate_test.insert_many([{"x": x} for x in range(num)]) + + self.app.register_resource( + "aggregate_test", + { + "datasource": { + "aggregation": {"pipeline": [{"$sort": SON([("x", -1)])}]} + } + }, + ) + + # first page + response, status = self.get("aggregate_test") + self.assert200(status) + + links = response["_links"] + self.assertNextLink(links, 2) + self.assertLastLink(links, 3) + self.assertPagination(response, 1, 75, 25) + + items = response["_items"] + expected_length = self.app.config["PAGINATION_DEFAULT"] + self.assertEqual(len(items), expected_length) + + item, value = 0, num - 1 + self.assertEqual(items[item]["x"], value) + item, value = expected_length - 1, num - expected_length + self.assertEqual(items[item]["x"], value) + + # second page + response, status = self.get("aggregate_test?page=2") + self.assert200(status) + + links = response["_links"] + self.assertNextLink(links, 3) + self.assertPrevLink(links, 1) + self.assertLastLink(links, 3) + self.assertPagination(response, 2, 75, 25) + + items = response["_items"] + expected_length = self.app.config["PAGINATION_DEFAULT"] + self.assertEqual(len(items), expected_length) + + item, value = 0, num - 1 - self.app.config["PAGINATION_DEFAULT"] + self.assertEqual(items[item]["x"], value) + item, value = expected_length - 1, num - expected_length * 2 + self.assertEqual(items[item]["x"], value) + + # third page + response, status = self.get("aggregate_test?page=3") + self.assert200(status) + + links = response["_links"] + self.assertPrevLink(links, 2) + self.assertLastLink(links, None) + self.assertPagination(response, 3, 75, 25) + + items = response["_items"] + expected_length = num - self.app.config["PAGINATION_DEFAULT"] * 2 + self.assertEqual(len(items), expected_length) + + item, value = 0, expected_length - 1 + self.assertEqual(items[item]["x"], value) + + item, value = expected_length - 1, 0 + self.assertEqual(items[item]["x"], 0) + + # pagination is disabled for the endpoint + self.domain["aggregate_test"]["pagination"] = False + # hence we get all documents with a single request + response, status = self.get("aggregate_test") + self.assert200(status) + items = response["_items"] + self.assertEqual(len(items), num) + # and pagination requests are ignored + response, status = self.get("aggregate_test?page=2") + self.assert200(status) + items = response["_items"] + self.assertEqual(len(items), num) + + def test_get_query_bitwise_query_operators(self): + del self.domain["contacts"]["schema"]["ref"]["required"] + response, status = self.delete(self.known_resource_url) + self.assert204(status) + + data = {"prog": 20} # 00010100 + response, status = self.post(self.known_resource_url, data=data) + self.assert201(status) + + where = '?where={"prog": {"$bitsAllClear": [1, 5]}}' + response, status = self.get(self.known_resource, where) + self.assert200(status) + items = response["_items"] + self.assertEqual(1, len(items)) + + where = '?where={"prog": {"$bitsAllClear": [2, 5]}}' + response, status = self.get(self.known_resource, where) + self.assert200(status) + items = response["_items"] + self.assertEqual(0, len(items)) + + def assertGet(self, response, status, resource=None): + self.assert200(status) + + links = response["_links"] + self.assertEqual(len(links), 4) + self.assertHomeLink(links) + if not resource: + resource = self.known_resource + self.assertResourceLink(links, resource) + self.assertNextLink(links, 2) + + resource = response["_items"] + self.assertEqual(len(resource), self.app.config["PAGINATION_DEFAULT"]) + + for item in resource: + self.assertItem(item, self.known_resource) + + etag = item.get(self.app.config["ETAG"]) + self.assertTrue(etag is not None) + + +class TestGetItem(TestBase): + def assertItemResponse(self, response, status, resource=None): + self.assert200(status) + self.assertTrue(self.app.config["ETAG"] in response) + links = response["_links"] + self.assertTrue(len(links) == 3 or len(links) == 4) + self.assertHomeLink(links) + self.assertCollectionLink(links, resource or self.known_resource) + self.assertItem(response, resource or self.known_resource) + + def test_disallowed_getitem(self): + _, status = self.get(self.empty_resource, item=self.item_id) + self.assert404(status) + + def test_getitem_by_id(self): + response, status = self.get(self.known_resource, item=self.item_id) + self.assertItemResponse(response, status) + + response, status = self.get(self.known_resource, item=self.unknown_item_id) + self.assert404(status) + + def test_getitem_internal_by_id(self): + with self.app.test_request_context(self.known_resource_url): + response, _, _, status = getitem_internal(self.known_resource) + self.assert200(status) + + def test_getitem_noschema(self): + self.app.config["DOMAIN"][self.known_resource]["schema"] = {} + response, status = self.get(self.known_resource, item=self.item_id) + self.assertItemResponse(response, status) + + def test_getitem_by_name(self): + response, status = self.get(self.known_resource, item=self.item_name) + self.assertItemResponse(response, status) + response, status = self.get(self.known_resource, item=self.unknown_item_name) + self.assert404(status) + + def test_getitem_by_name_self_href(self): + response, status = self.get(self.known_resource, item=self.item_id) + self_href = response["_links"]["self"]["href"] + + response, status = self.get(self.known_resource, item=self.item_name) + + self.assertEqual(self_href, response["_links"]["self"]["href"]) + + def test_getitem_by_integer(self): + self.domain["contacts"]["additional_lookup"] = {"field": "prog"} + self.app._add_resource_url_rules("contacts", self.domain["contacts"]) + response, status = self.get(self.known_resource, item=1) + self.assertItemResponse(response, status) + response, status = self.get(self.known_resource, item=self.known_resource_count) + self.assert404(status) + + def test_getitem_if_modified_since(self): + self.assertIfModifiedSince(self.item_id_url) + + def test_getitem_if_none_match(self): + r = self.test_client.get(self.item_id_url) + etag = r.headers.get("ETag") + self.assertTrue(etag is not None) + + # test that ETag is compliant to RFC 7232-2.3 and #794 is fixed. + self.assertTrue(etag[0] == '"') + self.assertTrue(etag[-1] == '"') + + r = self.test_client.get(self.item_id_url, headers=[("If-None-Match", etag)]) + self.assert304(r.status_code) + self.assertTrue(not r.get_data()) + + # test that we also support doublequote-less etags, for legacy + # reasons. See #794. + r = self.test_client.get( + self.item_id_url, headers=[("If-None-Match", etag.replace('"', ""))] + ) + self.assert304(r.status_code) + self.assertTrue(not r.get_data()) + + # test that we support weak etags + weak_etag = "W/" + etag + r = self.test_client.get( + self.item_id_url, headers=[("If-None-Match", weak_etag)] + ) + self.assert304(r.status_code) + self.assertTrue(not r.get_data()) + + def test_cache_control(self): + self.assertCacheControl(self.item_id_url) + + def test_expires(self): + self.assertExpires(self.item_id_url) + + def test_getitem_by_id_different_resource(self): + response, status = self.get(self.different_resource, item=self.user_id) + self.assertItemResponse(response, status, self.different_resource) + + response, status = self.get(self.different_resource, item=self.item_id) + self.assert404(status) + + def test_getitem_by_name_different_resource(self): + response, status = self.get(self.different_resource, item=self.user_username) + self.assertItemResponse(response, status, self.different_resource) + response, status = self.get( + self.different_resource, item=self.unknown_item_name + ) + self.assert404(status) + + def test_getitem_missing_standard_date_fields(self): + """Documents created outside the API context could be lacking the + LAST_UPDATED and/or DATE_CREATED fields. + """ + contacts = self.random_contacts(1, False) + ref = "test_update_field" + contacts[0]["ref"] = ref + _db = self.connection[MONGO_DBNAME] + _db.contacts.insert_one(contacts[0]) + response, status = self.get(self.known_resource, item=ref) + self.assertItemResponse(response, status) + + def test_get_with_post_override(self): + # POST request with GET override turns into a GET + headers = [("X-HTTP-Method-Override", "GET")] + r = self.test_client.post(self.item_id_url, headers=headers) + response, status = self.parse_response(r) + self.assertItemResponse(response, status) + + def test_getitem_embedded(self): + # We need to assign a `person` to our test invoice + _db = self.connection[MONGO_DBNAME] + + fake_contact = self.random_contacts(1)[0] + fake_contact_id = _db.contacts.insert_one(fake_contact).inserted_id + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, {"$set": {"person": fake_contact_id}} + ) + + invoices = self.domain["invoices"] + + # Test that we get 400 if can't parse dict + embedded = "not-a-dict" + r = self.test_client.get( + "%s/%s/%s" % (invoices["url"], self.invoice_id, "?embedded=%s" % embedded) + ) + self.assert400(r.status_code) + + # Test that doesn't come embedded if asking for a field that + # isn't embedded (global setting is True by default) + embedded = '{"person": 1}' + r = self.test_client.get( + "%s/%s/%s" % (invoices["url"], self.invoice_id, "?embedded=%s" % embedded) + ) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertTrue(content["person"], self.item_id) + + # Set field to be embedded + invoices["schema"]["person"]["data_relation"]["embeddable"] = True + + # Test that global setting applies even if field is set to embedded + invoices["embedding"] = False + r = self.test_client.get( + "%s/%s/%s" % (invoices["url"], self.invoice_id, "?embedded=%s" % embedded) + ) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertTrue(content["person"], self.item_id) + + # Test that it works + invoices["embedding"] = True + r = self.test_client.get( + "%s/%s/%s" % (invoices["url"], self.invoice_id, "?embedded=%s" % embedded) + ) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertTrue("location" in content["person"]) + + # Test that it ignores a bogus field + embedded = '{"person": 1, "not-a-real-field": 1}' + r = self.test_client.get( + "%s/%s/%s" % (invoices["url"], self.invoice_id, "?embedded=%s" % embedded) + ) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertTrue("location" in content["person"]) + + # Test that it ignores a real field with a bogus value + embedded = '{"person": 1, "inv_number": "not-a-real-value"}' + r = self.test_client.get( + "%s/%s/%s" % (invoices["url"], self.invoice_id, "?embedded=%s" % embedded) + ) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertTrue("location" in content["person"]) + + # Test that it works with item endpoint too + r = self.test_client.get( + "%s/%s/%s" % (invoices["url"], self.invoice_id, "?embedded=%s" % embedded) + ) + self.assert200(r.status_code) + content = json.loads(r.get_data()) + self.assertTrue("location" in content["person"]) + + # Test that changes to embedded document invalidate parent cache + invoice_last_modified = r.headers.get("Last-Modified") + contact_url = "%s/%s" % (self.domain["contacts"]["url"], fake_contact_id) + r = self.test_client.get(contact_url) + contact_etag = r.headers.get("Etag") + + # wait for contact and invoice updated at diff to pass 1s resolution + time.sleep(2) + changes = {"location": {"city": "new city"}} + response, status = self.patch( + contact_url, data=changes, headers=[("If-Match", contact_etag)] + ) + self.assert200(status) + + invoice_url = "%s/%s/%s" % ( + invoices["url"], + self.invoice_id, + "?embedded=%s" % embedded, + ) + r = self.test_client.get( + invoice_url, headers=[("If-Modified-Since", invoice_last_modified)] + ) + self.assert200(r.status_code) + + def test_subresource_getitem(self): + _db = self.connection[MONGO_DBNAME] + + # create random contact + fake_contact = self.random_contacts(1)[0] + fake_contact_id = _db.contacts.insert_one(fake_contact).inserted_id + # update first invoice to reference the new contact + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, {"$set": {"person": fake_contact_id}} + ) + + # GET all invoices by new contact + response, status = self.get( + "users/%s/invoices/%s" % (fake_contact_id, self.invoice_id) + ) + self.assert200(status) + self.assertEqual(response["person"], str(fake_contact_id)) + self.assertEqual(response["_id"], self.invoice_id) + + def test_getitem_data_relation_hateoas(self): + # We need to assign a `person` to our test invoice + _db = self.connection[MONGO_DBNAME] + + fake_contact = self.random_contacts(1)[0] + fake_contact_id = _db.contacts.insert_one(fake_contact).inserted_id + url = self.domain[self.known_resource]["url"] + item_title = self.domain[self.known_resource]["item_title"] + invoices = self.domain["invoices"] + + # Test nullable data relation fields + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, {"$set": {"person": None}} + ) + + response, status = self.get("%s/%s" % (invoices["url"], self.invoice_id)) + self.assertTrue("related" not in response["_links"]) + + # Test object id data relation fields + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, {"$set": {"person": fake_contact_id}} + ) + + response, status = self.get("%s/%s" % (invoices["url"], self.invoice_id)) + self.assertRelatedLink(response["_links"], "person") + related_links = response["_links"]["related"] + self.assertEqual(related_links["person"]["title"], item_title) + self.assertEqual( + related_links["person"]["href"], "%s/%s" % (url, fake_contact_id) + ) + + # Test DBRef data relation fields + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, + {"$set": {"persondbref": DBRef("contacts", fake_contact_id)}}, + ) + + response, status = self.get("%s/%s" % (invoices["url"], self.invoice_id)) + self.assertRelatedLink(response["_links"], "persondbref") + related_links = response["_links"]["related"] + self.assertEqual(related_links["persondbref"]["title"], item_title) + self.assertEqual( + related_links["persondbref"]["href"], "%s/%s" % (url, fake_contact_id) + ) + + # Test list of object id data relation fields + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, + {"$set": {"invoicing_contacts": [fake_contact_id] * 5}}, + ) + + response, status = self.get("%s/%s" % (invoices["url"], self.invoice_id)) + self.assertRelatedLink(response["_links"], "invoicing_contacts") + related_links = response["_links"]["related"] + self.assertEqual(len(related_links["invoicing_contacts"]), 5) + + def test_getitem_ifmatch_disabled(self): + # when IF_MATCH is disabled no etag is present in payload + self.app.config["IF_MATCH"] = False + response, _ = self.get(self.known_resource, item=self.item_id) + self.assertTrue(self.app.config["ETAG"] not in response) + + def test_getitem_ifmatch_disabled_if_mod_since(self): + # Test that #239 is fixed. + # IF_MATCH is disabled and If-Modified-Since request comes through. If + # a 304 was expected, we would crash like a mofo. + self.app.config["IF_MATCH"] = False + + # IMS needs to see as recent as possible since the test db has just + # been built + header = [("If-Modified-Since", date_to_rfc1123(datetime.utcnow()))] + + r = self.test_client.get(self.item_id_url, headers=header) + self.assert304(r.status_code) + + def test_getitem_custom_auto_document_fields(self): + self.app.config["LAST_UPDATED"] = "_updated_on" + self.app.config["DATE_CREATED"] = "_created_on" + self.app.config["ETAG"] = "_the_etag" + response, _ = self.get(self.known_resource, item=self.item_id) + self.assertTrue("_updated_on" in response) + self.assertTrue("_created_on" in response) + self.assertTrue("_the_etag" in response) + + def test_getitem_projection(self): + projection = '{"prog": 1}' + r, status = self.get( + self.known_resource, "?projection=%s" % projection, item=self.item_id + ) + self.assert200(status) + self.assertFalse("location" in r) + self.assertFalse("role" in r) + self.assertTrue("prog" in r) + self.assertTrue(self.domain[self.known_resource]["id_field"] in r) + self.assertTrue(self.app.config["ETAG"] in r) + self.assertTrue(self.app.config["LAST_UPDATED"] in r) + self.assertTrue(self.app.config["DATE_CREATED"] in r) + self.assertTrue(r[self.app.config["LAST_UPDATED"]] != self.epoch) + self.assertTrue(r[self.app.config["DATE_CREATED"]] != self.epoch) + + projection = '{"prog": 0}' + r, status = self.get( + self.known_resource, "?projection=%s" % projection, item=self.item_id + ) + self.assert200(status) + self.assertFalse("prog" in r) + self.assertTrue("location" in r) + self.assertTrue("role" in r) + self.assertTrue(self.domain[self.known_resource]["id_field"] in r) + self.assertTrue(self.app.config["ETAG"] in r) + self.assertTrue(self.app.config["LAST_UPDATED"] in r) + self.assertTrue(self.app.config["DATE_CREATED"] in r) + self.assertTrue(r[self.app.config["LAST_UPDATED"]] != self.epoch) + self.assertTrue(r[self.app.config["DATE_CREATED"]] != self.epoch) + + def test_getitem_lookup_field_as_string(self): + # Test that a resource where 'item_lookup_field' is set to a field + # of string type and which value is castable to a ObjectId is still + # treated as a string when 'query_objectid_as_string' is set to True. + # See PR #552. + data = {"id": "507c7f79bcf86cd7994f6c0e", "name": "john"} + response, status = self.post("ids", data=data) + self.assert201(status) + response, status = self.get("ids", item="507c7f79bcf86cd7994f6c0e") + self.assert200(status) + + def test_getitem_with_custom_idfield(self): + _db = self.connection[MONGO_DBNAME] + sku = _db.products.find()[0]["sku"] + response, status = self.get("products", item=sku) + self.assertItemResponse(response, status, "products") + + def test_getitem_optimize_pagination_next_link(self): + """When optimize_pagination_for_speed is enabled, the ``next`` link on + an item endpoint should use the collection URL, not the document URL. + Regression test for a bug where the next href incorrectly included the + document ID (e.g. ``resource/?page=2`` instead of + ``resource?page=2``). + """ + self.app.config["DOMAIN"][self.known_resource]["optimize_pagination_for_speed"] = True + + response, status = self.get(self.known_resource, item=self.item_id) + self.assert200(status) + + links = response["_links"] + self.assertIn("next", links) + + next_href = links["next"]["href"] + resource_url = self.domain[self.known_resource]["url"] + self.assertTrue( + next_href.startswith("%s?" % resource_url), + "Expected next href to start with '%s?' but got '%s'" + % (resource_url, next_href), + ) + self.assertNotIn(str(self.item_id), next_href) + + +class TestHead(TestBase): + def test_head_home(self): + self.assertHead("/") + + def test_head_resource(self): + self.assertHead(self.known_resource_url) + + def test_head_item(self): + self.assertHead(self.item_id_url) + + def assertHead(self, url): + h = self.test_client.head(url) + r = self.test_client.get(url) + self.assertTrue(not h.data) + + if "Expires" in r.headers: + # there's a tiny chance that the two expire values will differ by + # one second. See #316. + head_expire = str_to_date(r.headers.pop("Expires")) + get_expire = str_to_date(h.headers.pop("Expires")) + d = head_expire - get_expire + self.assertTrue(d.seconds in (0, 1)) + + self.assertEqual(r.headers, h.headers) + + +class TestEvents(TestBase): + def setUp(self): + super().setUp() + self.devent = DummyEvent(lambda: True) + + def test_on_pre_GET_for_item(self): + self.app.on_pre_GET += self.devent + self.get_item() + self.assertEqual("contacts", self.devent.called[0]) + self.assertFalse(self.devent.called[1] is None) + + def test_on_pre_GET_item_dynamic_filter(self): + def filter_this(resource, request, lookup): + lookup["_id"] = self.item_id + + self.app.on_pre_GET += filter_this + # Would normally return a 404; will return one instead. + r, s = self.parse_response(self.get_item()) + self.assert200(s) + self.assertEqual(r[self.domain[self.known_resource]["id_field"]], self.item_id) + + def test_on_pre_GET_resource_for_item(self): + self.app.on_pre_GET_contacts += self.devent + self.get_item() + self.assertFalse(self.devent.called is None) + + def test_on_pre_GET_for_resource(self): + self.app.on_pre_GET += self.devent + self.get_resource() + self.assertFalse(self.devent.called is None) + + def test_on_pre_GET_resource_dynamic_filter(self): + def filter_this(resource, request, lookup): + lookup["_id"] = self.item_id + + self.app.on_pre_GET += filter_this + # Would normally return all documents; will only just one. + r, s = self.parse_response(self.get_resource()) + self.assertEqual(len(r[self.app.config["ITEMS"]]), 1) + + def test_on_pre_GET_resource_dynamic_filter_12_chr_nonunicode_string(self): + # Test for bug in _mongotize(). See + # https://github.com/nicolaiarocci/eve/issues/508 + def filter_this(request, lookup): + request.args = ImmutableMultiDict({"where": '{"name":"Alice Brooks"}'}) + + self.app.register_resource("names", {"schema": {"name": {"type": "string"}}}) + # We want to test with a non-unicode string for 'where', so we need to + # do it with a pre_GET callback + self.app.on_pre_GET_names += filter_this + self.post("names", data={"name": "Alice Brooks"}) + r, s = self.get("names") + self.assertEqual(len(r[self.app.config["ITEMS"]]), 1) + + def test_on_pre_GET_resource_for_resource(self): + self.app.on_pre_GET_contacts += self.devent + self.get_resource() + self.assertFalse(self.devent.called is None) + + def test_on_post_GET_for_item(self): + self.app.on_post_GET += self.devent + self.get_item() + self.assertFalse(self.devent.called is None) + + def test_on_post_GET_resource_for_item(self): + self.app.on_post_GET_contacts += self.devent + self.get_item() + self.assertFalse(self.devent.called is None) + + def test_on_post_GET_for_resource(self): + self.app.on_post_GET += self.devent + self.get_resource() + self.assertFalse(self.devent.called is None) + + def test_on_post_GET_resource_for_resource(self): + self.app.on_post_GET_contacts += self.devent + self.get_resource() + self.assertFalse(self.devent.called is None) + + def test_on_post_GET_homepage(self): + self.app.on_post_GET += self.devent + self.test_client.get("/") + self.assertTrue(self.devent.called[0] is None) + self.assertEqual(3, len(self.devent.called)) + + def test_on_fetched_resource(self): + self.app.on_fetched_resource += self.devent + self.get_resource() + self.assertEqual("contacts", self.devent.called[0]) + self.assertEqual( + self.app.config["PAGINATION_DEFAULT"], + len(self.devent.called[1][self.app.config["ITEMS"]]), + ) + + def test_on_fetched_resource_contacts(self): + self.app.on_fetched_resource_contacts += self.devent + self.get_resource() + self.assertEqual( + self.app.config["PAGINATION_DEFAULT"], + len(self.devent.called[0][self.app.config["ITEMS"]]), + ) + + def test_on_fetched_item(self): + self.app.on_fetched_item += self.devent + self.get_item() + self.assertEqual("contacts", self.devent.called[0]) + id_field = self.domain[self.known_resource]["id_field"] + self.assertEqual(self.item_id, str(self.devent.called[1][id_field])) + self.assertEqual(2, len(self.devent.called)) + + def test_on_fetched_item_contacts(self): + self.app.on_fetched_item_contacts += self.devent + self.get_item() + id_field = self.domain[self.known_resource]["id_field"] + self.assertEqual(self.item_id, str(self.devent.called[0][id_field])) + self.assertEqual(1, len(self.devent.called)) + + def test_get_before_aggregation_hook(self): + _db = self.connection[MONGO_DBNAME] + _db.aggregate_test.insert_many( + [ + {"x": 1, "tags": ["dog", "cat"]}, + {"x": 2, "tags": ["cat"]}, + {"x": 2, "tags": ["mouse", "cat", "dog"]}, + {"x": 3, "tags": []}, + ] + ) + + self.app.before_aggregation += self.devent + + self.app.register_resource( + "aggregate_test", + { + "datasource": { + "aggregation": { + "pipeline": [ + {"$unwind": "$tags"}, + {"$group": {"_id": "$tags", "count": {"$sum": "$field1"}}}, + ] + } + } + }, + ) + + response, status = self.get("aggregate_test?aggregate=ciao") + self.assert400(status) + self.assertTrue(self.devent.called is None) + + response, status = self.get('aggregate_test?aggregate={"$field1":1}') + self.assert200(status) + self.assertEqual("aggregate_test", self.devent.called[0]) + + def test_get_after_aggregation_hook(self): + _db = self.connection[MONGO_DBNAME] + _db.aggregate_test.insert_many( + [ + {"x": 1, "tags": ["dog", "cat"]}, + {"x": 2, "tags": ["cat"]}, + {"x": 2, "tags": ["mouse", "cat", "dog"]}, + {"x": 3, "tags": []}, + ] + ) + + self.app.after_aggregation += self.devent + + self.app.register_resource( + "aggregate_test", + { + "datasource": { + "aggregation": { + "pipeline": [ + {"$unwind": "$tags"}, + {"$group": {"_id": "$tags", "count": {"$sum": "$field1"}}}, + ] + } + } + }, + ) + + response, status = self.get("aggregate_test?aggregate=ciao") + self.assert400(status) + self.assertTrue(self.devent.called is None) + + response, status = self.get('aggregate_test?aggregate={"$field1":1}') + self.assert200(status) + self.assertEqual("aggregate_test", self.devent.called[0]) + + def get_resource(self): + return self.test_client.get(self.known_resource_url) + + def get_item(self, url=None): + if not url: + url = self.item_id_url + return self.test_client.get(url) diff --git a/tests/methods/patch.py b/tests/methods/patch.py new file mode 100644 index 000000000..b26a1530e --- /dev/null +++ b/tests/methods/patch.py @@ -0,0 +1,940 @@ +import simplejson as json +from bson import ObjectId +from pymongo import ReadPreference + +from eve import ETAG, ISSUES, LAST_UPDATED, STATUS, STATUS_OK +from eve.methods.patch import patch_internal +from tests import TestBase +from tests.test_settings import MONGO_DBNAME +from tests.utils import DummyEvent + + +class TestPatch(TestBase): + def test_patch_not_override_other_fields(self): + self.app.config["ENFORCE_IF_MATCH"] = False + # create a data + r, status = self.post(self.test_patch_url, data={"name": "name", "contact": {}}) + self.assert201(status) + # check the data is created correctly + data, status = self.get(self.test_patch, item=r["_id"]) + self.assert200(status) + + # patch the data + _, status = self.patch( + self.test_patch_url + "/" + data["_id"], data={"contact.phone": "new_phone"} + ) + self.assert200(status) + # other fields should not be touched + data, status = self.get(self.test_patch, item=r["_id"]) + self.assert200(status) + self.assertEqual(data["name"], "name") + self.assertTrue("contact" in data) + self.assertEqual(data["contact"]["phone"], "new_phone") + self.assertEqual(data["contact"]["email"], "default_email") + + # patch other field of the data + _, status = self.patch( + self.test_patch_url + "/" + data["_id"], data={"contact.email": "new_email"} + ) + self.assert200(status) + # other fields should not be touched + data, status = self.get(self.test_patch, item=r["_id"]) + self.assert200(status) + self.assertEqual(data["name"], "name") + self.assertTrue("contact" in data) + self.assertEqual(data["contact"]["phone"], "new_phone") + self.assertEqual(data["contact"]["email"], "new_email") + + def test_patch_to_resource_endpoint(self): + _, status = self.patch(self.known_resource_url, data={}) + self.assert405(status) + + def test_readonly_resource(self): + _, status = self.patch(self.readonly_id_url, data={}) + self.assert405(status) + + def test_unknown_id(self): + _, status = self.patch(self.unknown_item_id_url, data={"key1": "value1"}) + self.assert404(status) + + def test_unknown_id_different_resource(self): + # patching a 'user' with a valid 'contact' id will 404 + _, status = self.patch( + "%s/%s/" % (self.different_resource, self.item_id), data={"key1": "value1"} + ) + self.assert404(status) + + # of course we can still patch a 'user' + _, status = self.patch( + "%s/%s/" % (self.different_resource, self.user_id), + data={"key1": '{"username": "username1"}'}, + headers=[("If-Match", self.user_etag)], + ) + self.assert200(status) + + def test_by_name(self): + _, status = self.patch(self.item_name_url, data={"key1": "value1"}) + self.assert405(status) + + def test_ifmatch_missing(self): + res, status = self.patch(self.item_id_url, data={"key1": "value1"}) + self.assert428(status) + + def test_ifmatch_missing_enforce_ifmatch_disabled(self): + self.app.config["ENFORCE_IF_MATCH"] = False + r, status = self.patch(self.item_id_url, data={"key1": "value1"}) + self.assert200(status) + self.assertTrue(ETAG in r) + + def test_ifmatch_disabled(self): + self.app.config["IF_MATCH"] = False + r, status = self.patch(self.item_id_url, data={"key1": "value1"}) + self.assert200(status) + self.assertTrue(ETAG not in r) + + def test_ifmatch_disabled_enforce_ifmatch_disabled(self): + self.app.config["ENFORCE_IF_MATCH"] = False + self.app.config["IF_MATCH"] = False + r, status = self.patch(self.item_id_url, data={"key1": "value1"}) + self.assert200(status) + self.assertTrue(ETAG not in r) + + def test_ifmatch_bad_etag(self): + _, status = self.patch( + self.item_id_url, + data={"key1": "value1"}, + headers=[("If-Match", "not-quite-right")], + ) + self.assert412(status) + + def test_ifmatch_bad_etag_enforce_ifmatch_disabled(self): + self.app.config["ENFORCE_IF_MATCH"] = False + _, status = self.patch( + self.item_id_url, + data={"key1": "value1"}, + headers=[("If-Match", "not-quite-right")], + ) + self.assert412(status) + + def test_unique_value(self): + # TODO + # for the time being we are happy with testing only Eve's custom + # validation. We rely on Cerberus' own test suite for other validation + # unit tests. This test also makes sure that response status is + # syntactically correct in case of validation issues. + # We should probably test every single case as well (seems overkill). + r, status = self.patch( + self.item_id_url, + data={"ref": "%s" % self.alt_ref}, + headers=[("If-Match", self.item_etag)], + ) + self.assertValidationErrorStatus(status) + self.assertValidationError( + r, {"ref": "value '%s' is not unique" % self.alt_ref} + ) + + def test_patch_string(self): + field = "ref" + test_value = "1234567890123456789012345" + changes = {field: test_value} + r = self.perform_patch(changes) + db_value = self.compare_patch_with_get(field, r) + self.assertEqual(db_value, test_value) + + def test_patch_integer(self): + field = "prog" + test_value = 9999 + changes = {field: test_value} + r = self.perform_patch(changes) + db_value = self.compare_patch_with_get(field, r) + self.assertEqual(db_value, test_value) + + def test_patch_list_as_array(self): + field = "role" + test_value = ["vendor", "client"] + changes = {field: test_value} + r = self.perform_patch(changes) + db_value = self.compare_patch_with_get(field, r) + self.assertTrue(set(test_value).issubset(db_value)) + + def test_patch_rows(self): + field = "rows" + test_value = [{"sku": "AT1234", "price": 99}, {"sku": "XF9876", "price": 9999}] + changes = {field: test_value} + r = self.perform_patch(changes) + db_value = self.compare_patch_with_get(field, r) + + for test_item in test_value: + self.assertTrue(test_item in db_value) + + def test_patch_list(self): + field = "alist" + test_value = ["a_string", 99] + changes = {field: test_value} + r = self.perform_patch(changes) + db_value = self.compare_patch_with_get(field, r) + self.assertEqual(db_value, test_value) + + def test_patch_dict(self): + field = "location" + test_value = {"address": "an address", "city": "a city"} + changes = {field: test_value} + original_city = [] + + def keep_original_city(resource_name, updates, original): + original_city.append(original["location"]["city"]) + + self.app.on_update += keep_original_city + self.app.on_updated += keep_original_city + r = self.perform_patch(changes) + db_value = self.compare_patch_with_get(field, r) + self.assertEqual(db_value, test_value) + self.assertEqual(original_city[0], original_city[1]) + + def test_patch_datetime(self): + field = "born" + test_value = "Tue, 06 Nov 2012 10:33:31 GMT" + changes = {field: test_value} + r = self.perform_patch(changes) + db_value = self.compare_patch_with_get(field, r) + self.assertEqual(db_value, test_value) + + def test_patch_objectid(self): + field = "tid" + test_value = "4f71c129c88e2018d4000000" + changes = {field: test_value} + r = self.perform_patch(changes) + db_value = self.compare_patch_with_get(field, r) + self.assertEqual(db_value, test_value) + + def test_patch_null_objectid(self): + # verify that #341 is fixed. + field = "tid" + test_value = None + changes = {field: test_value} + r = self.perform_patch(changes) + db_value = self.compare_patch_with_get(field, r) + self.assertEqual(db_value, test_value) + + def test_patch_missing_default(self): + """PATCH an object which is missing a field with a default value. + + This should result in setting the field to its default value, even if + the field is not provided in the PATCH's payload.""" + field = "ref" + test_value = "1234567890123456789012345" + changes = {field: test_value} + r = self.perform_patch(changes) + self.assertEqual( + self.compare_patch_with_get("unsetted_default_value_field", r), + self.domain["contacts"]["schema"]["unsetted_default_value_field"][ + "default" + ], + ) + + def test_patch_missing_default_with_post_override(self): + """PATCH an object which is missing a field with a default value. + + This should result in setting the field to its default value, even if + the field is not provided in the PATCH's payload.""" + field = "ref" + test_value = "1234567890123456789012345" + r = self.perform_patch_with_post_override(field, test_value) + self.assert200(r.status_code) + unsetted_default_value_field = self.compare_patch_with_get( + "unsetted_default_value_field", json.loads(r.get_data()) + ) + self.assertEqual( + unsetted_default_value_field, + self.domain["contacts"]["schema"]["unsetted_default_value_field"][ + "default" + ], + ) + + def test_patch_missing_nested_default(self): + """PATCH an object which is missing a field with a default value. + + This should result in setting the field to its default value, even if + the field is not provided in the PATCH's payload.""" + field = "dict_with_nested_default" + test_value = {} + changes = {field: test_value} + r = self.perform_patch(changes) + + item_id = r[self.domain[self.known_resource]["id_field"]] + raw_r = self.test_client.get("%s/%s" % (self.known_resource_url, item_id)) + item, status = self.parse_response(raw_r) + self.assertEqual( + item["dict_with_nested_default"], {"nested_field_with_default": "nested"} + ) + + def test_patch_multiple_fields(self): + fields = ["ref", "prog", "role"] + test_values = ["9876543210987654321054321", 123, ["agent"]] + changes = { + "ref": test_values[0], + "prog": test_values[1], + "role": test_values[2], + } + r = self.perform_patch(changes) + db_values = self.compare_patch_with_get(fields, r) + for i in range(len(db_values)): + self.assertEqual(db_values[i], test_values[i]) + + def test_patch_with_post_override(self): + # a POST request with PATCH override turns into a PATCH request + r = self.perform_patch_with_post_override("prog", 1) + self.assert200(r.status_code) + + def test_patch_internal(self): + # test that patch_internal is available and working properly. + test_field = "ref" + test_value = "9876543210987654321098765" + data = {test_field: test_value} + with self.app.test_request_context(self.item_id_url): + r, _, _, status = patch_internal( + self.known_resource, + data, + concurrency_check=False, + **{"_id": self.item_id} + ) + db_value = self.compare_patch_with_get(test_field, r) + self.assertEqual(db_value, test_value) + self.assert200(status) + + def test_patch_internal_with_options(self): + # test that patch_internal is available and working properly. + test_field = "ref" + test_value = "9876543210987654321098765" + data = {test_field: test_value} + mongo_options = {"read_preference": ReadPreference.PRIMARY} + with self.app.test_request_context(self.item_id_url): + r, _, _, status = patch_internal( + self.known_resource, + data, + concurrency_check=False, + mongo_options=mongo_options, + **{"_id": self.item_id} + ) + db_value = self.compare_patch_with_get(test_field, r) + self.assertEqual(db_value, test_value) + self.assert200(status) + + def test_patch_etag_header(self): + # test that Etag is always included with response header. See #562. + changes = {"ref": "1234567890123456789012345"} + headers = [("Content-Type", "application/json"), ("If-Match", self.item_etag)] + r = self.test_client.patch( + self.item_id_url, data=json.dumps(changes), headers=headers + ) + self.assertTrue("Etag" in r.headers) + + # test that ETag is compliant to RFC 7232-2.3 and #794 is fixed. + etag = r.headers["ETag"] + + self.assertTrue(etag[0] == '"') + self.assertTrue(etag[-1] == '"') + + def test_patch_etag_header_enforce_ifmatch_disabled(self): + self.app.config["ENFORCE_IF_MATCH"] = False + changes = {"ref": "1234567890123456789012345"} + headers = [("Content-Type", "application/json"), ("If-Match", self.item_etag)] + r, status = self.patch( + self.item_id_url, data=json.dumps(changes), headers=headers + ) + + self.assertTrue(ETAG in r) + self.assertTrue(self.item_etag != r[ETAG]) + + def test_patch_nested(self): + changes = { + "location.city": "a nested city", + "location.address": "a nested address", + } + r = self.perform_patch(changes) + values = self.compare_patch_with_get("location", r) + self.assertEqual(values["city"], "a nested city") + self.assertEqual(values["address"], "a nested address") + + def perform_patch(self, changes): + r, status = self.patch( + self.item_id_url, data=changes, headers=[("If-Match", self.item_etag)] + ) + self.assert200(status) + self.assertPatchResponse(r, self.item_id) + return r + + def perform_patch_with_post_override(self, field, value): + headers = [ + ("X-HTTP-Method-Override", "PATCH"), + ("If-Match", self.item_etag), + ("Content-Type", "application/json"), + ] + return self.test_client.post( + self.item_id_url, data=json.dumps({field: value}), headers=headers + ) + + def compare_patch_with_get(self, fields, patch_response): + raw_r = self.test_client.get(self.item_id_url) + r, status = self.parse_response(raw_r) + self.assert200(status) + self.assertEqual( + raw_r.headers.get("ETag").replace('"', ""), patch_response[ETAG] + ) + if isinstance(fields, str): + return r[fields] + return [r[field] for field in fields] + + def test_patch_allow_unknown(self): + changes = {"unknown": "unknown"} + r, status = self.patch( + self.item_id_url, data=changes, headers=[("If-Match", self.item_etag)] + ) + self.assertValidationErrorStatus(status) + self.assertValidationError(r, {"unknown": "unknown field"}) + self.app.config["DOMAIN"][self.known_resource]["allow_unknown"] = True + r, status = self.patch( + self.item_id_url, data=changes, headers=[("If-Match", self.item_etag)] + ) + self.assert200(status) + self.assertPatchResponse(r, self.item_id) + + def test_patch_x_www_form_urlencoded(self): + field = "ref" + test_value = "1234567890123456789012345" + changes = {field: test_value} + headers = [("If-Match", self.item_etag)] + r, status = self.parse_response( + self.test_client.patch(self.item_id_url, data=changes, headers=headers) + ) + self.assert200(status) + self.assertTrue("OK" in r[STATUS]) + + def test_patch_x_www_form_urlencoded_number_serialization(self): + del self.domain["contacts"]["schema"]["ref"]["required"] + field = "anumber" + test_value = 3.5 + changes = {field: test_value} + headers = [("If-Match", self.item_etag)] + r, status = self.parse_response( + self.test_client.patch(self.item_id_url, data=changes, headers=headers) + ) + self.assert200(status) + self.assertTrue("OK" in r[STATUS]) + + def test_patch_referential_integrity(self): + data = {"person": self.unknown_item_id} + headers = [("If-Match", self.invoice_etag)] + r, status = self.patch(self.invoice_id_url, data=data, headers=headers) + self.assertValidationErrorStatus(status) + expected = "value '%s' must exist in resource '%s', field '%s'" % ( + self.unknown_item_id, + "contacts", + self.domain["contacts"]["id_field"], + ) + self.assertValidationError(r, {"person": expected}) + + data = {"person": self.item_id} + r, status = self.patch(self.invoice_id_url, data=data, headers=headers) + self.assert200(status) + self.assertPatchResponse(r, self.invoice_id) + + def test_patch_write_concern_success(self): + # 0 and 1 are the only valid values for 'w' on our mongod instance (1 + # is the default) + self.domain["contacts"]["mongo_write_concern"] = {"w": 0} + field = "ref" + test_value = "X234567890123456789012345" + changes = {field: test_value} + _, status = self.patch( + self.item_id_url, data=changes, headers=[("If-Match", self.item_etag)] + ) + self.assert200(status) + + def test_patch_write_concern_fail(self): + # should get a 500 since there's no replicaset on the mongod instance + self.domain["contacts"]["mongo_write_concern"] = {"w": 2} + field = "ref" + test_value = "X234567890123456789012345" + changes = {field: test_value} + _, status = self.patch( + self.item_id_url, data=changes, headers=[("If-Match", self.item_etag)] + ) + self.assert500(status) + + def test_patch_missing_standard_date_fields(self): + """Documents created outside the API context could be lacking the + LAST_UPDATED and/or DATE_CREATED fields. + """ + # directly insert a document, without DATE_CREATED e LAST_UPDATED + # values. + contacts = self.random_contacts(1, False) + ref = "test_update_field" + contacts[0]["ref"] = ref + _db = self.connection[MONGO_DBNAME] + _db.contacts.insert_one(contacts[0]) + + # now retrieve same document via API and get its etag, which is + # supposed to be computed on default DATE_CREATED and LAST_UPDATAED + # values. + response, status = self.get(self.known_resource, item=ref) + etag = response[ETAG] + _id = response["_id"] + + # attempt a PATCH with the new etag. + field = "ref" + test_value = "X234567890123456789012345" + changes = {field: test_value} + _, status = self.patch( + "%s/%s" % (self.known_resource_url, _id), + data=changes, + headers=[("If-Match", etag)], + ) + self.assert200(status) + + def test_patch_subresource(self): + _db = self.connection[MONGO_DBNAME] + + # create random contact + fake_contact = self.random_contacts(1)[0] + fake_contact_id = _db.contacts.insert_one(fake_contact).inserted_id + + # update first invoice to reference the new contact + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, {"$set": {"person": fake_contact_id}} + ) + + # GET all invoices by new contact + response, status = self.get( + "users/%s/invoices/%s" % (fake_contact_id, self.invoice_id) + ) + etag = response[ETAG] + + data = {"inv_number": "new_number"} + headers = [("If-Match", etag)] + response, status = self.patch( + "users/%s/invoices/%s" % (fake_contact_id, self.invoice_id), + data=data, + headers=headers, + ) + self.assert200(status) + self.assertPatchResponse(response, self.invoice_id, "peopleinvoices") + + def test_patch_bandwidth_saver(self): + changes = {"ref": "1234567890123456789012345"} + + # bandwidth_saver is on by default + self.assertTrue(self.app.config["BANDWIDTH_SAVER"]) + r = self.perform_patch(changes) + self.assertFalse("ref" in r) + db_value = self.compare_patch_with_get(self.app.config["ETAG"], r) + self.assertEqual(db_value, r[self.app.config["ETAG"]]) + self.item_etag = r[self.app.config["ETAG"]] + + # test return all fields (bandwidth_saver off) + self.app.config["BANDWIDTH_SAVER"] = False + r = self.perform_patch(changes) + self.assertTrue("ref" in r) + db_value = self.compare_patch_with_get(self.app.config["ETAG"], r) + self.assertEqual(db_value, r[self.app.config["ETAG"]]) + + def test_patch_bandwidth_saver_credit_rule_broken(self): + _db = self.connection[MONGO_DBNAME] + rule = { + "amount": 300.0, + "duration": "months", + "name": "Testing BANDWIDTH_SAVER=False", + "start": "2020-03-28T06:00:00 UTC", + } + rule_id = _db.credit_rules.insert_one(rule).inserted_id + rule_url = "credit_rules/%s/" % (rule_id) + changes = { + "amount": 120.0, + "duration": "months", + "start": "2020-04-01T00:00:00 UTC", + } + response, _ = self.get("credit_rules/%s/" % (rule_id)) + etag = response[ETAG] + # bandwidth_saver is on by default + self.assertTrue(self.app.config["BANDWIDTH_SAVER"]) + self.assertTrue(self.app.config["PROJECTION"]) + r, status = self.patch(rule_url, data=changes, headers=[("If-Match", etag)]) + self.assert200(status) + self.assertPatchResponse(r, "%s" % (rule_id)) + self.assertFalse("amount" in r) + etag = r[self.app.config["ETAG"]] + r, _ = self.get(rule_url, "") + self.assertEqual(etag, r[self.app.config["ETAG"]]) + + # test return all fields (bandwidth_saver off) + self.app.config["BANDWIDTH_SAVER"] = False + changes["name"] = "Give it all to me!" + r, status = self.patch(rule_url, data=changes, headers=[("If-Match", etag)]) + self.assert200(status) + self.assertPatchResponse(r, "%s" % (rule_id)) + self.assertTrue( + all(["amount" in r, "duration" in r, "name" in r, "start" in r]), + 'One or more of "amount", "duration", "name", "start" is missing.', + ) + self.assertTrue(r["name"] == "Give it all to me!") + etag = r[self.app.config["ETAG"]] + r, status = self.get(rule_url, "") + self.assertEqual(etag, r[self.app.config["ETAG"]]) + + def test_patch_readonly_field_with_previous_document(self): + schema = self.domain["contacts"]["schema"] + del schema["ref"]["required"] + + # disable read-only on the field so we can store a value which is + # also different form its default value. + schema["read_only_field"]["readonly"] = False + changes = {"read_only_field": "value"} + r = self.perform_patch(changes) + + # resume read-only status for the field + self.domain["contacts"]["schema"]["read_only_field"]["readonly"] = True + + # test that if the read-only field is included with the payload and its + # value is equal to the one stored with the document, validation + # succeeds (#479). + etag = r["_etag"] + r, status = self.patch( + self.item_id_url, data=changes, headers=[("If-Match", etag)] + ) + self.assert200(status) + self.assertPatchResponse(r, self.item_id) + + # test that if the read-only field is included with the payload and its + # value is different from the stored document, validation fails. + etag = r["_etag"] + changes = {"read_only_field": "another value"} + r, status = self.patch( + self.item_id_url, data=changes, headers=[("If-Match", etag)] + ) + self.assert422(status) + self.assertTrue("is read-only" in r["_issues"]["read_only_field"]) + + def test_patch_nested_document_not_overwritten(self): + """Test that nested documents are not overwritten on PATCH and #519 + is fixed. + """ + + schema = { + "sensor": { + "type": "dict", + "schema": { + "name": {"type": "string"}, + "lon": {"type": "float"}, + "lat": {"type": "float"}, + "value": {"type": "float", "default": 10.3}, + "dict": { + "type": "dict", + "schema": { + "string": {"type": "string"}, + "int": {"type": "integer"}, + }, + }, + }, + }, + "test": {"type": "string", "readonly": True, "default": "default"}, + } + + self.app.config["BANDWIDTH_SAVER"] = False + self.app.register_resource("sensors", {"schema": schema}) + + changes = { + "sensor": { + "name": "device_name", + "lon": 43.4, + "lat": 1.31, + "dict": {"int": 99}, + } + } + r, status = self.post("sensors", data=changes) + self.assert201(status) + + id, etag, value, test, int = ( + r[self.domain["sensors"]["id_field"]], + r[ETAG], + r["sensor"]["value"], + r["test"], + r["sensor"]["dict"]["int"], + ) + + changes = {"sensor": {"lon": 10.0, "dict": {"string": "hi"}}} + + r, status = self.patch( + "/%s/%s" % ("sensors", id), data=changes, headers=[("If-Match", etag)] + ) + self.assert200(status) + + etag, value, int = (r[ETAG], r["sensor"]["value"], r["sensor"]["dict"]["int"]) + self.assertEqual(value, 10.3) + self.assertEqual(test, "default") + self.assertEqual(int, 99) + + def test_patch_nested_document_no_merge(self): + """Test that nested documents are not merged, but overwritten, + if configured.""" + domain = { + "merge_nested_documents": False, + "schema": {"nested": {"type": "dict"}}, + } + self.app.config["BANDWIDTH_SAVER"] = False + self.app.register_resource("nomerge", domain) + + original = {"nested": {"key1": "value1", "key2": "value2"}} + changes = {"nested": {"key2": "value2", "key3": "value3"}} + + r, status = self.post("nomerge", data=original) + self.assert201(status) + + id = r["_id"] + etag = r["_etag"] + + r, status = self.patch( + "/%s/%s" % ("nomerge", id), data=changes, headers=[("If-Match", etag)] + ) + self.assert200(status) + + # Assert that nested document was completely overwritten + self.assertEqual(r["nested"], changes["nested"]) + + def test_patch_nested_document_nullable_missing(self): + schema = { + "sensor": { + "type": "dict", + "schema": {"name": {"type": "string"}}, + "default": None, + "nullable": True, + }, + "other": {"type": "dict", "schema": {"name": {"type": "string"}}}, + } + self.app.config["BANDWIDTH_SAVER"] = False + self.app.register_resource("sensors", {"schema": schema}) + + changes = {} + + r, status = self.post("sensors", data=changes) + self.assert201(status) + id, etag = r[self.domain["sensors"]["id_field"]], r[ETAG] + self.assertTrue("sensor" in r) + self.assertEqual(r["sensor"], None) + self.assertFalse("other" in r) + + changes = {"sensor": {"name": "device_name"}, "other": {"name": "other_name"}} + + r, status = self.patch( + "/%s/%s" % ("sensors", id), data=changes, headers=[("If-Match", etag)] + ) + self.assert200(status) + self.assertEqual(r["sensor"], {"name": "device_name"}) + self.assertEqual(r["other"], {"name": "other_name"}) + + def test_patch_dependent_field_on_origin_document(self): + """Test that when patching a field which is dependent on another field's + existence, and this other field is not provided in the patch, but does + exist on the persisted document, the patch will be accepted. + + The value on the document can be there either because is was set + explicitly or because it was set as a default value by Eve. + + See #363. + """ + + # this will succeed as even if the value is not present in the PATCH + # payload, it is in the persisted document because the dependency_field1 + # had a default value defined + changes = {"dependency_field2": "value"} + r, status = self.patch( + self.item_id_url, data=changes, headers=[("If-Match", self.item_etag)] + ) + self.assert200(status) + + # this will fail, as dependent field is missing in the PATCH payload + # and is not present in the persisted document (it doesn't even have a + # default value) + etag = r["_etag"] + changes = {"dependency_field5": "value"} + r, status = self.patch( + self.item_id_url, data=changes, headers=[("If-Match", etag)] + ) + self.assert422(status) + + # update the stored document by adding the dependency field with some + # unknown value + changes = {"dependency_field4": "unknown_value"} + r, status = self.patch( + self.item_id_url, data=changes, headers=[("If-Match", etag)] + ) + self.assert200(status) + + # This will succeed as now the field is present in the persisted document + # even if it's not provided in the patch payload + etag = r["_etag"] + changes = {"dependency_field5": "value"} + r, status = self.patch( + self.item_id_url, data=changes, headers=[("If-Match", etag)] + ) + self.assert200(status) + + def test_patch_dependent_field_value_on_origin_document(self): + """Test that when patching a field which is dependent on another field's + value, and this other field is not provided in the patch, but is present + on the persisted document, the patch will be accepted. + + See #363. + """ + + # this will fail as the dependent field has value that doesn't + # document we are trying to update. + changes = {"dependency_field3": "value"} + r, status = self.patch( + self.item_id_url, data=changes, headers=[("If-Match", self.item_etag)] + ) + self.assert422(status) + + # update the stored document by setting the dependency field to + # the required value. + changes = {"dependency_field1": "value"} + r, status = self.patch( + self.item_id_url, data=changes, headers=[("If-Match", self.item_etag)] + ) + self.assert200(status) + + # now the field3 update will be accepted as the dependency field is + # present in the stored document already. + etag = r["_etag"] + changes = {"dependency_field3": "value"} + r, status = self.patch( + self.item_id_url, data=changes, headers=[("If-Match", etag)] + ) + self.assert200(status) + + def test_id_field_in_document_fails(self): + # since v0.6 we also allow the id field to be included with the POSTed + # document, but not with PATCH since it is immutable + self.app.config["IF_MATCH"] = False + id_field = self.domain[self.known_resource]["id_field"] + data = {id_field: "55b2340538345bd048100ffe"} + r, status = self.patch(self.item_id_url, data=data) + self.assert400(status) + self.assertTrue("immutable" in r["_error"]["message"]) + + def test_patch_custom_idfield(self): + response, status = self.get("products?max_results=1") + product = response["_items"][0] + headers = [("If-Match", product[ETAG])] + data = {"title": "Awesome product"} + r, status = self.patch( + "products/%s" % product["sku"], data=data, headers=headers + ) + self.assert200(status) + + def test_patch_type_coercion(self): + schema = self.domain[self.known_resource]["schema"] + schema["aninteger"]["coerce"] = lambda string: int(float(string)) + changes = {"ref": "1234567890123456789054321", "aninteger": "42.3"} + r, status = self.patch( + self.item_id_url, data=changes, headers=[("If-Match", self.item_etag)] + ) + self.assert200(status) + r, status = self.get(r["_links"]["self"]["href"]) + self.assertEqual(r["aninteger"], 42) + + def assertPatchResponse(self, response, item_id, resource=None): + id_field = self.domain[resource or self.known_resource]["id_field"] + self.assertTrue(STATUS in response) + self.assertTrue(STATUS_OK in response[STATUS]) + self.assertFalse(ISSUES in response) + self.assertTrue(id_field in response) + self.assertEqual(response[id_field], item_id) + self.assertTrue(LAST_UPDATED in response) + self.assertTrue(ETAG in response) + self.assertTrue("_links" in response) + self.assertItemLink(response["_links"], item_id) + + def patch(self, url, data, headers=[]): + headers.append(("Content-Type", "application/json")) + r = self.test_client.patch(url, data=json.dumps(data), headers=headers) + return self.parse_response(r) + + +class TestEvents(TestBase): + new_ref = "0123456789012345678901234" + + def test_on_pre_PATCH(self): + devent = DummyEvent(self.before_update) + self.app.on_pre_PATCH += devent + self.patch() + self.assertEqual(self.known_resource, devent.called[0]) + self.assertEqual(3, len(devent.called)) + + def test_on_pre_PATCH_contacts(self): + devent = DummyEvent(self.before_update) + self.app.on_pre_PATCH_contacts += devent + self.patch() + self.assertEqual(2, len(devent.called)) + + def test_on_PATCH_dynamic_filter(self): + def filter_this(resource, request, lookup): + lookup["_id"] = self.unknown_item_id + + self.app.on_pre_PATCH += filter_this + # Would normally patch the known document; will return 404 instead. + r, s = self.parse_response(self.patch()) + self.assert404(s) + + def test_on_post_PATCH(self): + devent = DummyEvent(self.after_update) + self.app.on_post_PATCH += devent + self.patch() + self.assertEqual(self.known_resource, devent.called[0]) + self.assertEqual(200, devent.called[2].status_code) + self.assertEqual(3, len(devent.called)) + + def test_on_post_PATCH_contacts(self): + devent = DummyEvent(self.after_update) + self.app.on_post_PATCH_contacts += devent + self.patch() + self.assertEqual(200, devent.called[1].status_code) + self.assertEqual(2, len(devent.called)) + + def test_on_update(self): + devent = DummyEvent(self.before_update) + self.app.on_update += devent + self.patch() + self.assertEqual(self.known_resource, devent.called[0]) + self.assertEqual(3, len(devent.called)) + + def test_on_update_contacts(self): + devent = DummyEvent(self.before_update) + self.app.on_update_contacts += devent + self.patch() + self.assertEqual(2, len(devent.called)) + + def test_on_updated(self): + devent = DummyEvent(self.after_update) + self.app.on_updated += devent + self.patch() + self.assertEqual(self.known_resource, devent.called[0]) + self.assertEqual(3, len(devent.called)) + + def test_on_updated_contacts(self): + devent = DummyEvent(self.after_update) + self.app.on_updated_contacts += devent + self.patch() + self.assertEqual(2, len(devent.called)) + + def before_update(self): + db = self.connection[MONGO_DBNAME] + contact = db.contacts.find_one(ObjectId(self.item_id)) + return contact["ref"] == self.item_name + + def after_update(self): + return not self.before_update() + + def patch(self): + headers = [("Content-Type", "application/json"), ("If-Match", self.item_etag)] + data = json.dumps({"ref": self.new_ref}) + return self.test_client.patch(self.item_id_url, data=data, headers=headers) diff --git a/tests/methods/patch_atomic_concurrency.py b/tests/methods/patch_atomic_concurrency.py new file mode 100644 index 000000000..8e7857746 --- /dev/null +++ b/tests/methods/patch_atomic_concurrency.py @@ -0,0 +1,76 @@ +import sys + +import simplejson as json + +import eve.methods.common +from eve.utils import config + +from tests import TestBase + +""" +Atomic Concurrency Checks + +Prior to commit 54fd697 from 2016-November, ETags would be verified +twice during a patch. One ETag check would be non-atomic by Eve, +then again atomically by MongoDB during app.data.update(filter). +The atomic ETag check was removed during issue #920 in 54fd697 + +When running Eve in a scale-out environment (multiple processes), +concurrent simultaneous updates are sometimes allowed, because +the Python-only ETag check is not atomic. + +There is a critical section in patch_internal() between get_document() +and app.data.update() where a competing Eve process can change the +document and ETag. + +This test simulates another process changing data & ETag during +the critical section. The test patches get_document() to return an +intentionally wrong ETag. +""" + + +def get_document_simulate_concurrent_update(*args, **kwargs): + """ + Hostile version of get_document + + This simluates another process updating MongoDB (and ETag) in + eve.methods.patch.patch_internal() during the critical area + between get_document() and app.data.update() + """ + document = eve.methods.common.get_document(*args, **kwargs) + document[config.ETAG] = "unexpected change!" + return document + + +class TestPatchAtomicConcurrent(TestBase): + def setUp(self): + """ + Patch eve.methods.patch.get_document with a hostile version + that simulates simultaneous updates + """ + self.original_get_document = sys.modules["eve.methods.patch"].get_document + sys.modules[ + "eve.methods.patch" + ].get_document = get_document_simulate_concurrent_update + return super().setUp() + + def test_etag_changed_after_get_document(self): + """ + Try to update a document after the ETag was adjusted + outside this process + """ + changes = {"ref": "1234567890123456789054321"} + _r, status = self.patch( + self.item_id_url, data=changes, headers=[("If-Match", self.item_etag)] + ) + self.assertEqual(status, 412) + + def tearDown(self): + """Remove patch of eve.methods.patch.get_document""" + sys.modules["eve.methods.patch"].get_document = self.original_get_document + return super().tearDown() + + def patch(self, url, data, headers=[]): + headers.append(("Content-Type", "application/json")) + r = self.test_client.patch(url, data=json.dumps(data), headers=headers) + return self.parse_response(r) diff --git a/tests/methods/post.py b/tests/methods/post.py new file mode 100644 index 000000000..f45585023 --- /dev/null +++ b/tests/methods/post.py @@ -0,0 +1,1209 @@ +from base64 import b64decode +from io import BytesIO + +import simplejson as json +from bson import ObjectId +from werkzeug.datastructures import MultiDict + +from eve import DATE_CREATED, ETAG, ISSUES, LAST_UPDATED, STATUS, STATUS_OK +from eve.methods.post import post, post_internal +from eve.utils import str_type +from tests import TestBase +from tests.test_settings import MONGO_DBNAME +from tests.utils import DummyEvent + + +class TestPost(TestBase): + def test_unknown_resource(self): + _, status = self.post(self.unknown_resource_url, data={}) + self.assert404(status) + + def test_readonly_resource(self): + _, status = self.post(self.readonly_resource_url, data={}) + self.assert405(status) + + def test_post_to_item_endpoint(self): + _, status = self.post(self.item_id_url, data={}) + self.assert405(status) + + def test_validation_error(self): + r, status = self.post(self.known_resource_url, data={"ref": "123"}) + self.assertValidationErrorStatus(status) + self.assertValidationError(r, {"ref": "min length is 25"}) + + r, status = self.post(self.known_resource_url, data={"prog": 123}) + self.assertValidationErrorStatus(status) + self.assertValidationError(r, {"ref": "required"}) + + def test_post_bulk_insert_on_disabled_bulk(self): + r, status = self.post( + self.disabled_bulk_url, + data=[{"string_field": "123"}, {"string_field": "123"}], + ) + self.assert400(status) + + def test_post_empty_bulk_insert(self): + r, status = self.post(self.empty_resource_url, data=[]) + self.assert400(status) + + def test_post_empty_resource(self): + data = [] + for _ in range(10): + data.append({"inv_number": self.random_string(10)}) + r, status = self.post(self.empty_resource_url, data=data) + self.assert201(status) + self.assertPostResponse(r) + + def test_post_string(self): + test_field = "ref" + test_value = "1234567890123456789054321" + data = {test_field: test_value} + self.assertPostItem(data, test_field, test_value) + + def test_post_duplicate_key(self): + data = {"ref": "1234567890123456789054321"} + r = self.perform_post(data) + id_field = self.domain[self.known_resource]["id_field"] + item_id = r[id_field] + data = {"ref": "0123456789012345678901234", id_field: item_id} + r, status = self.post(self.known_resource_url, data=data) + self.assertEqual(status, 409) + + def test_post_integer(self): + del self.domain["contacts"]["schema"]["ref"]["required"] + test_field = "prog" + test_value = 1 + data = {test_field: test_value} + self.assertPostItem(data, test_field, test_value) + + def test_post_list_as_array(self): + del self.domain["contacts"]["schema"]["ref"]["required"] + test_field = "role" + test_value = ["vendor", "client"] + data = {test_field: test_value} + self.assertPostItem(data, test_field, test_value) + + def test_post_rows(self): + del self.domain["contacts"]["schema"]["ref"]["required"] + test_field = "rows" + test_value = [{"sku": "AT1234", "price": 99}, {"sku": "XF9876", "price": 9999}] + data = {test_field: test_value} + self.assertPostItem(data, test_field, test_value) + + def test_post_list(self): + del self.domain["contacts"]["schema"]["ref"]["required"] + test_field = "alist" + test_value = ["a_string", 99] + data = {test_field: test_value} + self.assertPostItem(data, test_field, test_value) + + def test_post_integer_zero(self): + del self.domain["contacts"]["schema"]["ref"]["required"] + test_field = "aninteger" + test_value = 0 + data = {test_field: test_value} + self.assertPostItem(data, test_field, test_value) + + def test_post_float_zero(self): + del self.domain["contacts"]["schema"]["ref"]["required"] + test_field = "afloat" + test_value = 0.0 + data = {test_field: test_value} + self.assertPostItem(data, test_field, test_value) + + def test_post_dict(self): + del self.domain["contacts"]["schema"]["ref"]["required"] + test_field = "location" + test_value = {"address": "an address", "city": "a city"} + data = {test_field: test_value} + self.assertPostItem(data, test_field, test_value) + + def test_post_datetime(self): + del self.domain["contacts"]["schema"]["ref"]["required"] + test_field = "born" + test_value = "Tue, 06 Nov 2012 10:33:31 GMT" + data = {test_field: test_value} + self.assertPostItem(data, test_field, test_value) + + def test_post_objectid(self): + del self.domain["contacts"]["schema"]["ref"]["required"] + test_field = "tid" + test_value = "50656e4538345b39dd0414f0" + data = {test_field: test_value} + self.assertPostItem(data, test_field, test_value) + + def test_post_null_objectid(self): + # verify that #341 is fixed. + del self.domain["contacts"]["schema"]["ref"]["required"] + test_field = "tid" + test_value = None + data = {test_field: test_value} + self.assertPostItem(data, test_field, test_value) + + def test_post_default_value(self): + test_field = "unsetted_default_value_field" + test_value = self.domain["contacts"]["schema"]["unsetted_default_value_field"][ + "default" + ] + data = {"ref": "9234567890123456789054321"} + self.assertPostItem(data, test_field, test_value) + + def test_post_default_value_none(self): + # default values that assimilate to None (0, '', False) were ignored + # prior to 0.1.1 + title = self.domain["contacts"]["schema"]["title"] + title["default"] = "" + self.app.set_defaults() + data = {"ref": "UUUUUUUUUUUUUUUUUUUUUUUUU"} + self.assertPostItem(data, "title", "") + + title["type"] = "integer" + title["default"] = 0 + self.app.set_defaults() + data = {"ref": "TTTTTTTTTTTTTTTTTTTTTTTTT"} + self.assertPostItem(data, "title", 0) + + title["type"] = "boolean" + title["default"] = False + self.app.set_defaults() + data = {"ref": "QQQQQQQQQQQQQQQQQQQQQQQQQ"} + self.assertPostItem(data, "title", False) + + def test_multi_post_valid(self): + data = [ + {"ref": "9234567890123456789054321"}, + {"ref": "5432112345678901234567890", "role": ["agent"]}, + ] + r, status = self.post(self.known_resource_url, data=data) + self.assert201(status) + results = r["_items"] + + self.assertEqual(results[0]["_status"], "OK") + self.assertEqual(results[1]["_status"], "OK") + + with self.app.test_request_context(): + contacts = self.app.data.driver.db["contacts"] + r = contacts.count_documents({"ref": "9234567890123456789054321"}) + self.assertTrue(r == 1) + r = contacts.count_documents({"ref": "5432112345678901234567890"}) + self.assertTrue(r == 1) + + def test_multi_post_invalid(self): + data = [ + {"ref": "9234567890123456789054321"}, + {"prog": 9999}, + {"ref": "5432112345678901234567890", "role": ["agent"]}, + {"ref": self.item_ref}, + {"ref": "9234567890123456789054321", "tid": "12345678"}, + ] + r, status = self.post(self.known_resource_url, data=data) + self.assertValidationErrorStatus(status) + results = r["_items"] + + self.assertEqual(results[0]["_status"], "OK") + self.assertEqual(results[2]["_status"], "OK") + + self.assertValidationError(results[1], {"ref": "required"}) + self.assertValidationError(results[3], {"ref": "unique"}) + self.assertValidationError(results[4], {"tid": "objectid"}) + + id_field = self.domain[self.known_resource]["id_field"] + self.assertTrue(id_field not in results[0]) + self.assertTrue(id_field not in results[1]) + self.assertTrue(id_field not in results[2]) + self.assertTrue(id_field not in results[3]) + + with self.app.test_request_context(): + contacts = self.app.data.driver.db["contacts"] + r = contacts.count_documents({"prog": 9999}) + self.assertTrue(r == 0) + r = contacts.count_documents({"ref": "9234567890123456789054321"}) + self.assertTrue(r == 0) + + def test_post_x_www_form_urlencoded(self): + test_field = "ref" + test_value = "1234567890123456789054321" + data = {test_field: test_value} + r, status = self.parse_response( + self.test_client.post(self.known_resource_url, data=data) + ) + self.assert201(status) + self.assertTrue("OK" in r[STATUS]) + self.assertPostResponse(r) + + def test_post_x_www_form_urlencoded_number_serialization(self): + del self.domain["contacts"]["schema"]["ref"]["required"] + test_field = "anumber" + test_value = 34 + data = {test_field: test_value} + r, status = self.parse_response( + self.test_client.post(self.known_resource_url, data=data) + ) + self.assert201(status) + self.assertTrue("OK" in r[STATUS]) + self.assertPostResponse(r) + + def test_post_auto_collapse_multiple_keys(self): + self.app.config["AUTO_COLLAPSE_MULTI_KEYS"] = True + self.app.register_resource( + "test_res", + {"schema": {"list_field": {"type": "list", "schema": {"type": "string"}}}}, + ) + + data = MultiDict([("list_field", "value1"), ("list_field", "value2")]) + resp = self.test_client.post( + "/test_res/", data=data, content_type="application/x-www-form-urlencoded" + ) + r, status = self.parse_response(resp) + self.assert201(status) + + resp = self.test_client.post( + "/test_res/", data=data, content_type="multipart/form-data" + ) + r, status = self.parse_response(resp) + self.assert201(status) + + def test_post_auto_collapse_media_list(self): + self.app.config["AUTO_COLLAPSE_MULTI_KEYS"] = True + self.app.register_resource( + "test_res", + {"schema": {"list_field": {"type": "list", "schema": {"type": "media"}}}}, + ) + + # Create a document + data = MultiDict( + [ + ("list_field", (BytesIO(b"file_content1"), "test1.txt")), + ("list_field", (BytesIO(b"file_content2"), "test2.txt")), + ] + ) + resp = self.test_client.post( + "/test_res/", data=data, content_type="multipart/form-data" + ) + r, status = self.parse_response(resp) + self.assert201(status) + + # check that the files were created + _db = self.connection[MONGO_DBNAME] + id_field = self.domain["test_res"]["id_field"] + obj = _db.test_res.find_one({id_field: ObjectId(r[id_field])}) + media_ids = obj["list_field"] + self.assertEqual(len(media_ids), 2) + with self.app.test_request_context(): + for i in [0, 1]: + self.assertTrue(self.app.media.exists(media_ids[i], "test_res")) + + # GET the document and check the file content is correct + r, status = self.parse_response( + self.test_client.get("/test_res/%s" % r[id_field]) + ) + files = r["list_field"] + self.assertEqual(b64decode(files[0]), b"file_content1") + self.assertEqual(b64decode(files[1]), b"file_content2") + + # DELETE the document + resp = self.test_client.delete( + "/test_res/%s" % r["_id"], headers={"If-Match": r["_etag"]} + ) + r, status = self.parse_response(resp) + self.assert204(status) + + # Check files were deleted + with self.app.test_request_context(): + for i in [0, 1]: + self.assertFalse(self.app.media.exists(media_ids[i], "test_res")) + + def test_post_auto_create_lists(self): + self.app.config["AUTO_CREATE_LISTS"] = True + self.app.register_resource( + "test_res", + {"schema": {"list_field": {"type": "list", "schema": {"type": "string"}}}}, + ) + + data = MultiDict([("list_field", "value1")]) + resp = self.test_client.post( + "/test_res/", data=data, content_type="application/x-www-form-urlencoded" + ) + r, status = self.parse_response(resp) + self.assert201(status) + + def test_post_decimal_number_success(self): + data = {"decimal_number": 100} + r, status = self.post("/invoices/", data=data) + self.assert201(status) + self.assertPostResponse(r) + id_field = self.domain["invoices"]["id_field"] + unique_id = r[id_field] + r, status = self.get("invoices/%s" % unique_id) + self.assert200(status) + assert isinstance(r["decimal_number"], str_type) + + def test_post_decimal_number_fail(self): + data = {"decimal_number": "100.0.0"} + r, status = self.post("/invoices/", data=data) + self.assert422(status) + + def test_post_referential_integrity(self): + data = {"person": self.unknown_item_id} + r, status = self.post("/invoices/", data=data) + self.assertValidationErrorStatus(status) + expected = "value '%s' must exist in resource '%s', field '%s'" % ( + self.unknown_item_id, + "contacts", + self.domain["contacts"]["id_field"], + ) + self.assertValidationError(r, {"person": expected}) + + data = {"person": self.item_id} + r, status = self.post("/invoices/", data=data) + self.assert201(status) + self.assertPostResponse(r) + + def test_dbref_post_referential_integrity(self): + data = {"persondbref": {"$col": "contacts", "$id": self.unknown_item_id}} + r, status = self.post("/invoices/", data=data) + self.assertValidationErrorStatus(status) + expected = "value '%s' must exist in resource '%s', field '%s'" % ( + self.unknown_item_id, + "contacts", + self.domain["contacts"]["id_field"], + ) + + self.assertValidationError(r, {"persondbref": expected}) + + data = {"persondbref": {"$col": "contacts", "$id": self.item_id}} + r, status = self.post("/invoices/", data=data) + self.assert201(status) + self.assertPostResponse(r) + + def test_post_referential_integrity_list(self): + data = {"invoicing_contacts": [self.item_id, self.unknown_item_id]} + r, status = self.post("/invoices/", data=data) + self.assertValidationErrorStatus(status) + expected = "value '%s' must exist in resource '%s', field '%s'" % ( + self.unknown_item_id, + "contacts", + self.domain["contacts"]["id_field"], + ) + self.assertValidationError(r, {"invoicing_contacts": expected}) + + data = {"invoicing_contacts": [self.item_id, self.item_id]} + r, status = self.post("/invoices/", data=data) + self.assert201(status) + self.assertPostResponse(r) + + def test_post_allow_unknown(self): + del self.domain["contacts"]["schema"]["ref"]["required"] + data = {"unknown": "unknown"} + r, status = self.post(self.known_resource_url, data=data) + self.assertValidationErrorStatus(status) + self.assertValidationError(r, {"unknown": "unknown"}) + + # since resource settings are only set at app startup we set + # those that influence the 'allow_unknown' property by hand (so we + # don't have to re-initialize the whole app.) + settings = self.app.config["DOMAIN"][self.known_resource] + settings["allow_unknown"] = True + settings["datasource"]["projection"] = {} + + r, status = self.post(self.known_resource_url, data=data) + self.assert201(status) + self.assertPostResponse(r) + + # test that the unknown field is also returned with subsequent get + # requests + id = r[self.domain[self.known_resource]["id_field"]] + r = self.test_client.get("%s/%s" % (self.known_resource_url, id)) + r_data = json.loads(r.get_data()) + self.assertTrue("unknown" in r_data) + self.assertEqual("unknown", r_data["unknown"]) + + def test_post_mapping_allow_unknown_allowed(self): + schema = { + "data": { + "type": "dict", + "allow_unknown": True, + "schema": {"prop": {"type": "string"}}, + } + } + settings = {"RESOURCE_METHODS": ["GET", "POST", "DELETE"], "schema": schema} + self.app.register_resource("endpoint", settings) + + data = {"data": {"prop": "test prop", "test": "test"}} + r, status = self.post("endpoint", data=data) + self.assert201(status) + + def test_post_with_content_type_charset(self): + test_field = "ref" + test_value = "1234567890123456789054321" + data = {test_field: test_value} + r, status = self.post( + self.known_resource_url, + data=data, + content_type="application/json; charset=utf-8", + ) + self.assert201(status) + self.assertPostResponse(r) + + def test_post_with_extra_response_fields(self): + self.domain["contacts"]["extra_response_fields"] = ["ref", "notreally"] + test_field = "ref" + test_value = "1234567890123456789054321" + data = {test_field: test_value} + r, status = self.post(self.known_resource_url, data=data) + self.assert201(status) + self.assertTrue("ref" in r and "notreally" not in r) + + def test_post_with_excluded_response_fields(self): + data = {"email": "test@email.com", "password": "password"} + r, status = self.post("login", data=data) + self.assert201(status) + + login_id = r[self.domain["login"]["id_field"]] + r = self.test_client.get("%s/%s" % ("login", login_id)) + r_data = json.loads(r.get_data()) + self.assertTrue("password" not in r_data) + self.assertTrue("email" in r_data) + + def test_post_write_concern(self): + # should get a 500 since there's no replicaset on mongod test instance + self.domain["contacts"]["mongo_write_concern"] = {"w": 2} + test_field = "ref" + test_value = "1234567890123456789054321" + data = {test_field: test_value} + _, status = self.post(self.known_resource_url, data=data) + self.assert500(status) + # 0 and 1 are the only valid values for 'w' on our mongod instance + self.domain["contacts"]["mongo_write_concern"] = {"w": 0} + test_value = "1234567890123456789054329" + data = {test_field: test_value} + _, status = self.post(self.known_resource_url, data=data) + self.assert201(status) + + def test_post_with_get_override(self): + # a GET request with POST override turns into a POST request. + test_field = "ref" + test_value = "1234567890123456789054321" + data = json.dumps({test_field: test_value}) + headers = [ + ("X-HTTP-Method-Override", "POST"), + ("Content-Type", "application/json"), + ] + r = self.test_client.get(self.known_resource_url, data=data, headers=headers) + self.assert201(r.status_code) + self.assertPostResponse(json.loads(r.get_data())) + + def test_post_list_of_objectid(self): + objectid = "50656e4538345b39dd0414f0" + del self.domain["contacts"]["schema"]["ref"]["required"] + data = {"id_list": ["%s" % objectid]} + r, status = self.post(self.known_resource_url, data=data) + self.assert201(status) + r, status = self.get( + self.known_resource, '?where={"id_list": ' '{"$in": ["%s"]}}' % objectid + ) + self.assert200(status) + self.assertTrue(len(r), 1) + self.assertTrue("%s" % objectid in r["_items"][0]["id_list"]) + + def test_post_nested_dict_objectid(self): + objectid = "50656e4538345b39dd0414f0" + del self.domain["contacts"]["schema"]["ref"]["required"] + data = {"id_list_of_dict": [{"id": "%s" % objectid}]} + r, status = self.post(self.known_resource_url, data=data) + self.assert201(status) + r, status = self.get( + self.known_resource, '?where={"id_list_of_dict.id": ' '"%s"}' % objectid + ) + self.assertTrue(len(r), 1) + self.assertTrue("%s" % objectid in r["_items"][0]["id_list_of_dict"][0]["id"]) + + def test_post_valueschema_with_objectid(self): + del self.domain["contacts"]["schema"]["ref"]["required"] + data = {"dict_valueschema": {"id": {"challenge": "50656e4538345b39dd0414f0"}}} + r, status = self.post(self.known_resource_url, data=data) + self.assert201(status) + + def test_post_list_fixed_len(self): + objectid = "50656e4538345b39dd0414f0" + del self.domain["contacts"]["schema"]["ref"]["required"] + data = {"id_list_fixed_len": ["%s" % objectid]} + r, status = self.post(self.known_resource_url, data=data) + self.assert201(status) + r, status = self.get( + self.known_resource, + '?where={"id_list_fixed_len": ' '{"$in": ["%s"]}}' % objectid, + ) + self.assert200(status) + self.assertTrue(len(r), 1) + self.assertTrue("%s" % objectid in r["_items"][0]["id_list_fixed_len"]) + + def test_custom_issues(self): + self.app.config["ISSUES"] = "errors" + r, status = self.post(self.known_resource_url, data={"ref": "123"}) + self.assertValidationErrorStatus(status) + self.assertTrue("errors" in r and ISSUES not in r) + + def test_custom_status(self): + self.app.config["STATUS"] = "report" + r, status = self.post(self.known_resource_url, data={"ref": "123"}) + self.assertValidationErrorStatus(status) + self.assertTrue("report" in r and STATUS not in r) + + def test_custom_etag_update_date(self): + self.app.config["ETAG"] = "_myetag" + r, status = self.post( + self.known_resource_url, data={"ref": "1234567890123456789054321"} + ) + self.assert201(status) + self.assertTrue("_myetag" in r and ETAG not in r) + + def test_custom_date_updated(self): + self.app.config["LAST_UPDATED"] = "_update_date" + r, status = self.post( + self.known_resource_url, data={"ref": "1234567890123456789054321"} + ) + self.assert201(status) + self.assertTrue("_update_date" in r and LAST_UPDATED not in r) + + def test_subresource(self): + response, status = self.post("users/%s/invoices" % self.item_id, data={}) + self.assert201(status) + self.assertPostResponse(response) + + invoice_id = response.get(self.domain["peopleinvoices"]["id_field"]) + response, status = self.get("users/%s/invoices/%s" % (self.item_id, invoice_id)) + self.assert200(status) + self.assertEqual(response.get("person"), self.item_id) + + def test_subresource_required_ref(self): + response, status = self.post( + "users/%s/required_invoices" % self.item_id, data={} + ) + self.assert201(status) + self.assertPostResponse(response) + + invoice_id = response.get(self.domain["required_invoices"]["id_field"]) + response, status = self.get( + "users/%s/required_invoices/%s" % (self.item_id, invoice_id) + ) + self.assert200(status) + self.assertEqual(response.get("person"), self.item_id) + + def test_post_ifmatch_disabled(self): + # if IF_MATCH is disabled, then we get no etag in the payload. + self.app.config["IF_MATCH"] = False + test_field = "ref" + test_value = "1234567890123456789054321" + data = {test_field: test_value} + r, status = self.post(self.known_resource_url, data=data) + self.assertTrue(ETAG not in r) + + def test_post_custom_idfield(self): + # Test that we can post a document with a custom id_field. + id_field = "sku" + product = {id_field: "FOO", "title": "Foobar"} + r, status = self.post("products", data=product) + self.assert201(status) + self.assertTrue(id_field in r) + self.assertItemLink(r["_links"], r[id_field]) + + def test_post_with_relation_to_custom_idfield(self): + # Test that we can post a document that relates to a resource with a + # custom id_field. + id_field = "sku" + db = self.connection[MONGO_DBNAME] + existing_product = db.products.find_one() + product = { + id_field: "BAR", + "title": "Foobar", + "parent_product": existing_product[id_field], + } + r, status = self.post("products", data=product) + self.assert201(status) + self.assertTrue(id_field in r) + self.assertItemLink(r["_links"], r[id_field]) + r, status = self.get("products", item="BAR") + self.assertEqual(r["parent_product"], existing_product[id_field]) + + def test_post_bandwidth_saver(self): + data = {"inv_number": self.random_string(10)} + + # bandwidth_saver is on by default + self.assertTrue(self.app.config["BANDWIDTH_SAVER"]) + r, status = self.post(self.empty_resource_url, data=data) + self.assert201(status) + self.assertPostResponse(r) + self.assertFalse("inv_number" in r) + etag = r[self.app.config["ETAG"]] + r, status = self.get( + self.empty_resource, "", r[self.domain[self.empty_resource]["id_field"]] + ) + self.assertEqual(etag, r[self.app.config["ETAG"]]) + + # test return all fields (bandwidth_saver off) + self.app.config["BANDWIDTH_SAVER"] = False + r, status = self.post(self.empty_resource_url, data=data) + self.assert201(status) + self.assertPostResponse(r) + self.assertTrue("inv_number" in r) + etag = r[self.app.config["ETAG"]] + r, status = self.get( + self.empty_resource, "", r[self.domain[self.empty_resource]["id_field"]] + ) + self.assertEqual(etag, r[self.app.config["ETAG"]]) + + def test_post_bandwidth_saver_credit_rule_broken(self): + data = [ + { + "amount": 300.0, + "duration": "months", + "name": "Bandwidth Saver:True, Projection:True", + "start": "2020-03-28T06:00:00 UTC", + } + ] + + # bandwidth_saver is on by default + self.assertTrue(self.app.config["BANDWIDTH_SAVER"]) + self.assertTrue(self.app.config["PROJECTION"]) + r, status = self.post("credit_rules", data=data) + self.assert201(status) + self.assertPostResponse(r) + self.assertFalse("amount" in r) + etag = r[self.app.config["ETAG"]] + r, status = self.get( + "credit_rules", "", r[self.domain["credit_rules"]["id_field"]] + ) + self.assertEqual(etag, r[self.app.config["ETAG"]]) + + # test return all fields (bandwidth_saver off) + self.app.config["BANDWIDTH_SAVER"] = False + r, status = self.post("credit_rules", data=data) + self.assert201(status) + self.assertPostResponse(r) + self.assertTrue( + all(["amount" in r, "duration" in r, "name" in r, "start" in r]), + 'One or more of "amount", "duration", "name", "start" is missing.', + ) + etag = r[self.app.config["ETAG"]] + r, status = self.get( + "credit_rules", "", r[self.domain["credit_rules"]["id_field"]] + ) + self.assertEqual(etag, r[self.app.config["ETAG"]]) + + def test_post_alternative_payload(self): + payl = {"ref": "5432112345678901234567890", "role": ["agent"]} + with self.app.test_request_context(self.known_resource_url): + r, _, _, status, _ = post(self.known_resource, payl=payl) + self.assert201(status) + self.assertPostResponse(r) + + def test_post_dependency_fields_with_default(self): + # test that default values are resolved before validation. See #353. + del self.domain["contacts"]["schema"]["ref"]["required"] + test_field = "dependency_field2" + test_value = "a value" + data = {test_field: test_value} + self.assertPostItem(data, test_field, test_value) + + def test_post_dependency_required_fields(self): + del self.domain["contacts"]["schema"]["ref"]["required"] + schema = self.domain["contacts"]["schema"] + schema["dependency_field3"]["required"] = True + + r, status = self.post(self.known_resource_url, data={}) + self.assertValidationErrorStatus(status) + self.assertValidationError(r, {"dependency_field3": "required"}) + + # required field dependnecy value matches the dependent field's default + # value. validation still fails since required field is still missing. + # See #665. + schema["dependency_field3"]["dependencies"] = {"dependency_field1": "default"} + r, status = self.post(self.known_resource_url, data={}) + self.assertValidationErrorStatus(status) + self.assertValidationError(r, {"dependency_field3": "required"}) + + r, status = self.post( + self.known_resource_url, data={"dependency_field3": "hello"} + ) + self.assert201(status) + + def test_post_dependency_fields_with_values(self): + # test that dependencies values are validated correctly. See #547. + del self.domain["contacts"]["schema"]["ref"]["required"] + + schema = { + "field1": {"required": False, "default": "one"}, + "field2": {"required": True, "dependencies": {"field1": ["one", "two"]}}, + } + settings = { + "RESOURCE_METHODS": ["GET", "POST", "DELETE"], + "ITEM_METHODS": ["GET", "PATCH", "PUT", "DELETE"], + "schema": schema, + } + self.app.register_resource("posts", settings) + + data = {"field1": "three", "field2": 7} + r, s = self.post("posts", data=data) + self.assert422(s) + + data = {"field2": 7} + r, s = self.post("posts", data=data) + self.assert201(s) + + data = {"field1": "one", "field2": 7} + r, s = self.post("posts", data=data) + self.assert201(s) + + data = {"field1": "two", "field2": 7} + r, s = self.post("posts", data=data) + self.assert201(s) + + def test_post_dependency_fields_with_subdocuments(self): + # test that dependencies with sub-document fields are properly + # validated. See #706. + del self.domain["contacts"]["schema"]["ref"]["required"] + + schema = { + "field1": {"type": "dict", "schema": {"address": {"type": "string"}}}, + "field2": {"dependencies": {"field1.address": ["one", "two"]}}, + } + settings = { + "RESOURCE_METHODS": ["GET", "POST", "DELETE"], + "ITEM_METHODS": ["GET", "PATCH", "PUT", "DELETE"], + "schema": schema, + } + self.app.register_resource("endpoint", settings) + + data = {"field1": {"address": "three"}, "field2": 7} + r, s = self.post("endpoint", data=data) + self.assert422(s) + + data = {"field1": {"address": "one"}, "field2": 7} + r, s = self.post("endpoint", data=data) + self.assert201(s) + + data = {"field1": {"address": "two"}, "field2": 7} + r, s = self.post("endpoint", data=data) + self.assert201(s) + + def test_post_readonly_field_with_default(self): + # test that a read only field with a 'default' setting is correctly + # validated now that we resolve field values before validation. + del self.domain["contacts"]["schema"]["ref"]["required"] + test_field = "read_only_field" + # thou shalt not pass. + test_value = "a random value" + data = {test_field: test_value} + r, status = self.post(self.known_resource_url, data=data) + self.assertValidationErrorStatus(status) + # this will not pass even if value matches 'default' setting. + # (hey it's still a read-onlu field so you can't reset it) + test_value = "default" + data = {test_field: test_value} + r, status = self.post(self.known_resource_url, data=data) + self.assertValidationErrorStatus(status) + + def test_post_with_nested_default(self): + """Test that in post of a field that has nested fields with default values + those default values are set + """ + del self.domain["contacts"]["schema"]["ref"]["required"] + test_field = "dict_with_nested_default" + test_value = {} + data = {test_field: test_value} + r, status = self.post(self.known_resource_url, data=data) + self.assert201(status) + + item_id = r[self.domain[self.known_resource]["id_field"]] + raw_r = self.test_client.get("%s/%s" % (self.known_resource_url, item_id)) + item, status = self.parse_response(raw_r) + self.assertEqual( + item["dict_with_nested_default"], {"nested_field_with_default": "nested"} + ) + + def test_post_readonly_in_dict(self): + # Test that a post with a readonly field inside a dict is properly + # validated (even if it has a default value) + del self.domain["contacts"]["schema"]["ref"]["required"] + test_field = "dict_with_read_only" + test_value = {"read_only_in_dict": "default"} + data = {test_field: test_value} + r, status = self.post(self.known_resource_url, data=data) + self.assertValidationErrorStatus(status) + + def test_post_valueschema_dict(self): + """make sure Cerberus#48 is fixed""" + del self.domain["contacts"]["schema"]["ref"]["required"] + r, status = self.post( + self.known_resource_url, data={"valueschema_dict": {"k1": "1"}} + ) + self.assertValidationErrorStatus(status) + issues = r[ISSUES] + self.assertTrue("valueschema_dict" in issues) + self.assertEqual(issues["valueschema_dict"], {"k1": "must be of integer type"}) + + r, status = self.post( + self.known_resource_url, data={"valueschema_dict": {"k1": 1}} + ) + self.assert201(status) + + def test_post_keyschema_dict(self): + del self.domain["contacts"]["schema"]["ref"]["required"] + + r, status = self.post( + self.known_resource_url, data={"keyschema_dict": {"aaa": 1}} + ) + self.assert201(status) + + r, status = self.post( + self.known_resource_url, data={"keyschema_dict": {"AAA": "1"}} + ) + self.assertValidationErrorStatus(status) + + issues = r[ISSUES] + self.assertTrue("keyschema_dict" in issues) + self.assertEqual( + issues["keyschema_dict"], {"AAA": "value does not match regex '[a-z]+'"} + ) + + def test_post_internal(self): + # test that post_internal is available and working properly. + test_field = "ref" + test_value = "1234567890123456789054321" + payload = {test_field: test_value} + with self.app.test_request_context(self.known_resource_url): + r, _, _, status, _ = post_internal(self.known_resource, payl=payload) + self.assert201(status) + + def test_post_internal_skip_validation(self): + # test that when skip_validation is active everything behaves as + # expected. Also make sure that #726 is fixed. + test_field = "ref" + test_value = "1234567890123456789054321" + payload = {test_field: test_value} + with self.app.test_request_context(self.known_resource_url): + r, _, _, status, _ = post_internal( + self.known_resource, payl=payload, skip_validation=True + ) + self.assert201(status) + + def test_post_nested(self): + del self.domain["contacts"]["schema"]["ref"]["required"] + data = { + "location.city": "a nested city", + "location.address": "a nested address", + } + r, status = self.post(self.known_resource_url, data=data) + self.assert201(status) + values = self.compare_post_with_get( + r[self.domain[self.known_resource]["id_field"]], ["location"] + ).pop() + self.assertEqual(values["city"], "a nested city") + self.assertEqual(values["address"], "a nested address") + + def test_post_error_as_list(self): + del self.domain["contacts"]["schema"]["ref"]["required"] + self.app.config["VALIDATION_ERROR_AS_LIST"] = True + data = {"unknown_field": "a value"} + r, status = self.post(self.known_resource_url, data=data) + self.assert422(status) + error = r[ISSUES]["unknown_field"] + self.assertTrue(isinstance(error, list)) + + def test_id_field_included_with_document(self): + # since v0.6 we also allow the id field to be included with the POSTed + # document + id_field = self.domain[self.known_resource]["id_field"] + id = "55b2340538345bd048100ffe" + data = {"ref": "1234567890123456789054321", id_field: id} + r, status = self.post(self.known_resource_url, data=data) + self.assert201(status) + self.assertPostResponse(r) + self.assertEqual(r["_id"], id) + + def test_post_type_coercion(self): + schema = self.domain[self.known_resource]["schema"] + schema["aninteger"]["coerce"] = lambda string: int(float(string)) + data = {"ref": "1234567890123456789054321", "aninteger": "42.3"} + self.assertPostItem(data, "aninteger", 42) + + def test_post_location_header_hateoas_on(self): + self.app.config["HATEOAS"] = True + data = json.dumps({"ref": "1234567890123456789054321"}) + headers = [("Content-Type", "application/json")] + r = self.test_client.post(self.known_resource_url, data=data, headers=headers) + self.assertTrue("Location" in r.headers) + self.assertTrue(self.known_resource_url in r.headers["Location"]) + + def test_post_location_header_hateoas_off(self): + self.app.config["HATEOAS"] = False + data = json.dumps({"ref": "1234567890123456789054321"}) + headers = [("Content-Type", "application/json")] + r = self.test_client.post(self.known_resource_url, data=data, headers=headers) + self.assertTrue("Location" in r.headers) + self.assertTrue(self.known_resource_url in r.headers["Location"]) + + def test_post_custom_json_content_type(self): + data = {"ref": "1234567890123456789054321"} + r, status = self.post( + self.known_resource_url, data, content_type="application/csp-report" + ) + self.assert400(status) + + self.app.config["JSON_REQUEST_CONTENT_TYPES"] += ["application/csp-report"] + r, status = self.post( + self.known_resource_url, data, content_type="application/csp-report" + ) + self.assert201(status) + + def test_post_updating_a_document_with_nullable_data_relation_does_not_fail(self): + # See #1159. + del self.domain["contacts"]["schema"]["ref"]["required"] + + employee = { + "employer": { + "type": "objectid", + "nullable": True, + "data_relation": {"resource": self.known_resource}, + } + } + self.app.register_resource("employee", {"schema": employee}) + + data = {"employer": None} + r, s = self.post("employee", data=data) + self.assert201(s) + + employee["employer"]["nullable"] = False + r, s = self.post("employee", data=data) + self.assert422(s) + + del employee["employer"]["nullable"] + r, s = self.post("employee", data=data) + self.assert422(s) + + def test_post_dont_normalize_dotted_fields(self): + # Allow skipping of default dotted field normalization, mostly useful + # for custom data layers such as eve_elastic. See #1173. + self.app.register_resource( + "test", + {"normalize_dotted_fields": False, "schema": {"a_dict": {"type": "dict"}}}, + ) + self.app.config["BANDWIDTH_SAVER"] = False + + data = {"a_dict": {"dotted.field": True}} + headers = [("Content-Type", "application/json")] + resp = self.test_client.post("test/", data=json.dumps(data), headers=headers) + _, status = self.parse_response(resp) + self.assertTrue(json.loads(resp.data)["a_dict"]["dotted.field"]) + + def test_post_projection_is_honored(self): + data = {"ref": "1234567890123456789054321", "aninteger": 100} + self.app.config["BANDWIDTH_SAVER"] = False + self.domain["contacts"]["datasource"]["projection"] = {"ref": 1} + + r, status = self.post(self.known_resource_url, data=data) + self.assert201(status) + self.assertPostResponse(r) + self.assertTrue("ref" in r) + self.assertTrue("aninteger" not in r) + + def test_unique_within_resource_value_different_resources(self): + r, status = self.post("tenant_a", data={"name": "John"}) + self.assert201(status) + r, status = self.post("tenant_b", data={"name": "John"}) + self.assert201(status) + + def test_unique_within_resource_in_resource_without_filter(self): + def make_payload(unique_value): + return {"unique_within_resource_attribute": unique_value} + + r, status = self.post("test_unique", data=make_payload("unique_value")) + self.assert201(status) + r, status = self.post("test_unique", data=make_payload("unique_value")) + self.assert422(status) + r, status = self.post("test_unique", data=make_payload("unique_value_2")) + self.assert201(status) + + def test_unique_in_root_attribute(self): + def make_payload(unique_value): + return {"unique_attribute": unique_value} + + r, status = self.post("test_unique", data=make_payload("unique_value")) + self.assert201(status) + r, status = self.post("test_unique", data=make_payload("unique_value")) + self.assert422(status) + r, status = self.post("test_unique", data=make_payload("unique_value_2")) + self.assert201(status) + + def test_unique_in_dict_attribute(self): + def make_payload(unique_value): + return {"unique_in_dict_attribute": {"unique_attribute": unique_value}} + + r, status = self.post("test_unique", data=make_payload("unique_value")) + self.assert201(status) + r, status = self.post("test_unique", data=make_payload("unique_value")) + self.assert422(status) + r, status = self.post("test_unique", data=make_payload("unique_value_2")) + self.assert201(status) + + def test_unique_in_list_attribute(self): + def make_payload(unique_value): + return {"unique_in_list_attribute": [{"unique_attribute": unique_value}]} + + r, status = self.post("test_unique", data=make_payload("unique_value")) + self.assert201(status) + r, status = self.post("test_unique", data=make_payload("unique_value")) + self.assert422(status) + r, status = self.post("test_unique", data=make_payload("unique_value_2")) + self.assert201(status) + + def test_unique_in_deep_dict_attribute(self): + def make_payload(unique_value): + return { + "unique_in_deep_dict_attribute": { + "dict_attribute": {"unique_attribute": unique_value} + } + } + + r, status = self.post("test_unique", data=make_payload("unique_value")) + self.assert201(status) + r, status = self.post("test_unique", data=make_payload("unique_value")) + self.assert422(status) + r, status = self.post("test_unique", data=make_payload("unique_value_2")) + self.assert201(status) + + def test_unique_in_deep_list_attribute(self): + def make_payload(unique_value): + return { + "unique_in_deep_list_attribute": { + "list_attribute": [{"unique_attribute": unique_value}] + } + } + + r, status = self.post("test_unique", data=make_payload("unique_value")) + self.assert201(status) + r, status = self.post("test_unique", data=make_payload("unique_value")) + self.assert422(status) + r, status = self.post("test_unique", data=make_payload("unique_value_2")) + self.assert201(status) + + def perform_post(self, data, valid_items=[0]): + r, status = self.post(self.known_resource_url, data=data) + self.assert201(status) + self.assertPostResponse(r, valid_items) + return r + + def assertPostItem(self, data, test_field, test_value): + r = self.perform_post(data) + item_id = r[self.domain[self.known_resource]["id_field"]] + item_etag = r[ETAG] + db_value = self.compare_post_with_get(item_id, [test_field, ETAG]) + self.assertTrue(db_value[0] == test_value) + self.assertTrue(db_value[1] == item_etag) + + def assertPostResponse(self, response, valid_items=[0], resource=None): + if "_items" in response: + results = response["_items"] + else: + results = [response] + + id_field = self.domain[resource or self.known_resource]["id_field"] + + for i in valid_items: + item = results[i] + self.assertTrue(STATUS in item) + self.assertTrue(STATUS_OK in item[STATUS]) + self.assertFalse(ISSUES in item) + self.assertTrue(id_field in item) + self.assertTrue(LAST_UPDATED in item) + self.assertTrue("_links" in item) + self.assertItemLink(item["_links"], item[id_field]) + self.assertTrue(ETAG in item) + + def compare_post_with_get(self, item_id, fields): + raw_r = self.test_client.get("%s/%s" % (self.known_resource_url, item_id)) + item, status = self.parse_response(raw_r) + id_field = self.domain[self.known_resource]["id_field"] + self.assert200(status) + self.assertTrue(id_field in item) + self.assertTrue(item[id_field] == item_id) + self.assertTrue(DATE_CREATED in item) + self.assertTrue(LAST_UPDATED in item) + self.assertEqual(item[DATE_CREATED], item[LAST_UPDATED]) + if isinstance(fields, list): + return [item[field] for field in fields] + return item[fields] + + def post(self, url, data, headers=None, content_type="application/json"): + if not headers: + headers = [] + headers.append(("Content-Type", content_type)) + r = self.test_client.post(url, data=json.dumps(data), headers=headers) + return self.parse_response(r) + + +class TestEvents(TestBase): + new_contact_id = "0123456789012345678901234" + + def test_on_pre_POST(self): + devent = DummyEvent(self.before_insert) + self.app.on_pre_POST += devent + self.post() + self.assertFalse(devent.called is None) + + def test_on_pre_POST_contacts(self): + devent = DummyEvent(self.before_insert) + self.app.on_pre_POST_contacts += devent + self.post() + self.assertFalse(devent.called is None) + + def test_on_post_POST(self): + devent = DummyEvent(self.after_insert) + self.app.on_post_POST += devent + self.post() + self.assertEqual(devent.called[0], self.known_resource) + + def test_on_POST_post_resource(self): + devent = DummyEvent(self.after_insert) + self.app.on_post_POST_contacts += devent + self.post() + self.assertFalse(devent.called is None) + + def test_on_insert(self): + devent = DummyEvent(self.before_insert, True) + self.app.on_insert += devent + self.post() + self.assertEqual(self.known_resource, devent.called[0]) + self.assertEqual(self.new_contact_id, devent.called[1][0]["ref"]) + + def test_on_insert_contacts(self): + devent = DummyEvent(self.before_insert, True) + self.app.on_insert_contacts += devent + self.post() + self.assertEqual(self.new_contact_id, devent.called[0][0]["ref"]) + + def test_on_inserted(self): + devent = DummyEvent(self.after_insert, True) + self.app.on_inserted += devent + self.post() + self.assertEqual(self.known_resource, devent.called[0]) + self.assertEqual(self.new_contact_id, devent.called[1][0]["ref"]) + + def test_on_inserted_contacts(self): + devent = DummyEvent(self.after_insert, True) + self.app.on_inserted_contacts += devent + self.post() + self.assertEqual(self.new_contact_id, devent.called[0][0]["ref"]) + + def post(self): + headers = [("Content-Type", "application/json")] + data = json.dumps({"ref": self.new_contact_id}) + self.test_client.post(self.known_resource_url, data=data, headers=headers) + + def before_insert(self): + db = self.connection[MONGO_DBNAME] + return db.contacts.find_one({"ref": self.new_contact_id}) is None + + def after_insert(self): + return not self.before_insert() diff --git a/tests/methods/put.py b/tests/methods/put.py new file mode 100644 index 000000000..f5f5312ee --- /dev/null +++ b/tests/methods/put.py @@ -0,0 +1,683 @@ +import simplejson as json +from bson import ObjectId +from bson.dbref import DBRef + +from eve import ETAG, ISSUES, LAST_UPDATED, STATUS, STATUS_OK +from eve.methods.put import put_internal +from tests import TestBase +from tests.test_settings import MONGO_DBNAME +from tests.utils import DummyEvent + + +class TestPut(TestBase): + # TODO consider making a base codebase out of 'patch' and 'put' tests + def test_put_to_resource_endpoint(self): + _, status = self.put(self.known_resource_url, data={}) + self.assert405(status) + + def test_readonly_resource(self): + _, status = self.put(self.readonly_id_url, data={}) + self.assert405(status) + + def test_by_name(self): + _, status = self.put(self.item_name_url, data={"key1": "value1"}) + self.assert405(status) + + def test_ifmatch_missing(self): + _, status = self.put(self.item_id_url, data={"key1": "value1"}) + self.assert428(status) + + def test_ifmatch_missing_enforce_ifmatch_disabled(self): + self.app.config["ENFORCE_IF_MATCH"] = False + + def test_ifmatch_disabled(self): + self.app.config["IF_MATCH"] = False + r, status = self.put( + self.item_id_url, data={"ref": "1234567890123456789012345"} + ) + self.assert200(status) + self.assertTrue(ETAG not in r) + + def test_ifmatch_disabled_enforce_ifmatch_disabled(self): + self.app.config["IF_MATCH"] = False + self.app.config["ENFORCE_IF_MATCH"] = False + r, status = self.put( + self.item_id_url, data={"ref": "1234567890123456789012345"} + ) + self.assert200(status) + self.assertTrue(ETAG not in r) + + def test_ifmatch_bad_etag(self): + _, status = self.put( + self.item_id_url, + data={"key1": "value1"}, + headers=[("If-Match", "not-quite-right")], + ) + self.assert412(status) + + def test_ifmatch_bad_etag_enforce_ifmatch_disabled(self): + self.app.config["ENFORCE_IF_MATCH"] = False + _, status = self.put( + self.item_id_url, + data={"key1": "value1"}, + headers=[("If-Match", "not-quite-right")], + ) + self.assert412(status) + + def test_unique_value(self): + r, status = self.put( + self.item_id_url, + data={"ref": "%s" % self.alt_ref}, + headers=[("If-Match", self.item_etag)], + ) + self.assertValidationErrorStatus(status) + self.assertValidationError( + r, {"ref": "value '%s' is not unique" % self.alt_ref} + ) + + def test_unique_idfield(self): + self.domain["products"]["schema"]["sku"]["unique"] = True + response, status = self.get("products?max_results=1") + product = response["_items"][0] + headers = [("If-Match", product[ETAG])] + updated_product = { + "sku": product["sku"], + "title": product["title"], + "parent_product": product["parent_product"], + } + r, status = self.put( + "products/%s" % product["sku"], data=updated_product, headers=headers + ) + self.assert200(status) + + def test_allow_unknown(self): + changes = {"unknown": "unknown"} + r, status = self.put( + self.item_id_url, data=changes, headers=[("If-Match", self.item_etag)] + ) + self.assertValidationErrorStatus(status) + self.assertValidationError(r, {"unknown": "unknown field"}) + self.app.config["DOMAIN"][self.known_resource]["allow_unknown"] = True + changes = {"unknown": "unknown", "ref": "1234567890123456789012345"} + r, status = self.put( + self.item_id_url, data=changes, headers=[("If-Match", self.item_etag)] + ) + self.assert200(status) + self.assertPutResponse(r, self.item_id) + + def test_put_x_www_form_urlencoded(self): + field = "ref" + test_value = "1234567890123456789012345" + changes = {field: test_value} + headers = [("If-Match", self.item_etag)] + r, status = self.parse_response( + self.test_client.put(self.item_id_url, data=changes, headers=headers) + ) + self.assert200(status) + self.assertTrue("OK" in r[STATUS]) + + def test_put_x_www_form_urlencoded_number_serialization(self): + del self.domain["contacts"]["schema"]["ref"]["required"] + field = "anumber" + test_value = 41 + changes = {field: test_value} + headers = [("If-Match", self.item_etag)] + r, status = self.parse_response( + self.test_client.put(self.item_id_url, data=changes, headers=headers) + ) + self.assert200(status) + self.assertTrue("OK" in r[STATUS]) + + def test_put_referential_integrity(self): + data = {"person": self.unknown_item_id} + headers = [("If-Match", self.invoice_etag)] + r, status = self.put(self.invoice_id_url, data=data, headers=headers) + self.assertValidationErrorStatus(status) + expected = "value '%s' must exist in resource '%s', field '%s'" % ( + self.unknown_item_id, + "contacts", + self.domain["contacts"]["id_field"], + ) + self.assertValidationError(r, {"person": expected}) + + data = {"person": self.item_id} + r, status = self.put(self.invoice_id_url, data=data, headers=headers) + self.assert200(status) + self.assertPutResponse(r, self.invoice_id, "invoices") + + def test_put_referential_integrity_list(self): + data = {"invoicing_contacts": [self.item_id, self.unknown_item_id]} + headers = [("If-Match", self.invoice_etag)] + r, status = self.put(self.invoice_id_url, data=data, headers=headers) + self.assertValidationErrorStatus(status) + expected = "value '%s' must exist in resource '%s', field '%s'" % ( + self.unknown_item_id, + "contacts", + self.domain["contacts"]["id_field"], + ) + self.assertValidationError(r, {"invoicing_contacts": expected}) + + data = {"invoicing_contacts": [self.item_id, self.item_id]} + r, status = self.put(self.invoice_id_url, data=data, headers=headers) + self.assert200(status) + self.assertPutResponse(r, self.invoice_id, "invoices") + + def test_put_write_concern_success(self): + # 0 and 1 are the only valid values for 'w' on our mongod instance (1 + # is the default) + self.domain["contacts"]["mongo_write_concern"] = {"w": 0} + field = "ref" + test_value = "X234567890123456789012345" + changes = {field: test_value} + _, status = self.put( + self.item_id_url, data=changes, headers=[("If-Match", self.item_etag)] + ) + self.assert200(status) + + def test_put_write_concern_fail(self): + # should get a 500 since there's no replicaset on the mongod instance + self.domain["contacts"]["mongo_write_concern"] = {"w": 2} + field = "ref" + test_value = "X234567890123456789012345" + changes = {field: test_value} + _, status = self.put( + self.item_id_url, data=changes, headers=[("If-Match", self.item_etag)] + ) + self.assert500(status) + + def test_put_string(self): + field = "ref" + test_value = "1234567890123456789012345" + changes = {field: test_value} + r = self.perform_put(changes) + db_value = self.compare_put_with_get(field, r) + self.assertEqual(db_value, test_value) + + def test_put_with_post_override(self): + # POST request with PUT override turns into a PUT + field = "ref" + test_value = "1234567890123456789012345" + changes = {field: test_value} + headers = [ + ("X-HTTP-Method-Override", "PUT"), + ("If-Match", self.item_etag), + ("Content-Type", "application/x-www-form-urlencoded"), + ] + r = self.test_client.post(self.item_id_url, data=changes, headers=headers) + self.assert200(r.status_code) + self.assertPutResponse(json.loads(r.get_data()), self.item_id) + + def test_put_sets_default_value_when_field_not_provided_neither_persisted(self): + """ + Test that when replacing a document, any field that has default values + defined in the schema is set according to the schema default when + the current persisted document doesn't have the field value set. + """ + test_field = "unsetted_default_value_field" + test_value = self.domain["contacts"]["schema"]["unsetted_default_value_field"][ + "default" + ] + data = {"ref": "9234567890123456789054321"} + r = self.perform_put(data) + db_value = self.compare_put_with_get(test_field, r) + self.assertEqual(test_value, db_value) + + def test_put_sets_default_value_when_field_not_provided_but_persisted(self): + """ + Test that when replacing a document, any field that has default values + defined in the schema is set according to the schema default when + the current persisted document already had the field value set. + + This effectively makes impossible to delete fields with default values + in the schema using a PUT request. + """ + test_field = "title" + test_value = "Mr." + data = {"ref": "9234567890123456789054321"} + r = self.perform_put(data) + db_value = self.compare_put_with_get(test_field, r) + self.assertEqual(test_value, db_value) + + def test_put_removes_non_provided_non_default_field(self): + """ + Test that when replacing a document, any field that has doesn't have + a default value defined in the schema and has not been provided in + the request will be effectively deleted in the replaced version. + + """ + data = {"ref": "9234567890123456789054321"} + r = self.perform_put(data) + + item_id = r[self.domain[self.known_resource]["id_field"]] + raw_r = self.test_client.get("%s/%s" % (self.known_resource_url, item_id)) + item, status = self.parse_response(raw_r) + + meta_fields = ["_etag", "_updated", "_id", "_links", "_created"] + explicitly_set_fields = ["ref"] + fields_with_defaults = [ + "unsetted_default_value_field", + "ref", + "dependency_field1", + "title", + "read_only_field", + ] + + self.assertEqual( + set(meta_fields + explicitly_set_fields + fields_with_defaults), + set(item.keys()), + ) + + def test_put_readonly_value_same(self): + data = { + "ref": self.item["ref"], + "read_only_field": self.item["read_only_field"], + } + r, status = self.put( + self.item_id_url, data=data, headers=[("If-Match", self.item_etag)] + ) + self.assert200(status) + + def test_put_readonly_value_different(self): + field = "read_only_field" + data = {"ref": self.item["ref"], field: "somethingelse"} + r, status = self.put( + self.item_id_url, data=data, headers=[("If-Match", self.item_etag)] + ) + self.assert422(status) + self.assertValidationError(r, {field: "field is read-only"}) + + def test_put_subresource(self): + _db = self.connection[MONGO_DBNAME] + self.app.config["BANDWIDTH_SAVER"] = False + + # create random contact + fake_contact = self.random_contacts(1)[0] + fake_contact_id = _db.contacts.insert_one(fake_contact).inserted_id + + # update first invoice to reference the new contact + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, {"$set": {"person": fake_contact_id}} + ) + + # GET all invoices by new contact + response, status = self.get( + "users/%s/invoices/%s" % (fake_contact_id, self.invoice_id) + ) + etag = response[ETAG] + + data = {"inv_number": "new_number"} + headers = [("If-Match", etag)] + response, status = self.put( + "users/%s/invoices/%s" % (fake_contact_id, self.invoice_id), + data=data, + headers=headers, + ) + self.assert200(status) + self.assertPutResponse(response, self.invoice_id, "peopleinvoices") + self.assertEqual(response.get("person"), str(fake_contact_id)) + + def test_put_dbref_subresource(self): + _db = self.connection[MONGO_DBNAME] + self.app.config["BANDWIDTH_SAVER"] = False + + # create random contact + fake_contact = self.random_contacts(1)[0] + fake_contact_id = _db.contacts.insert_one(fake_contact).inserted_id + + # update first invoice to reference the new contact + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, + { + "$set": { + "person": fake_contact_id, + "persondbref": DBRef("contacts", ObjectId(fake_contact_id)), + } + }, + ) + + # GET all invoices by new contact + response, status = self.get( + "users/%s/invoices/%s" % (fake_contact_id, self.invoice_id) + ) + + self.assertEqual(response.get("persondbref")["$id"], str(fake_contact_id)) + + etag = response[ETAG] + + data = {"inv_number": "new_number"} + headers = [("If-Match", etag)] + response, status = self.put( + "users/%s/invoices/%s" % (fake_contact_id, self.invoice_id), + data=data, + headers=headers, + ) + + self.assert200(status) + self.assertPutResponse(response, self.invoice_id, "peopleinvoices") + + def test_put_bandwidth_saver(self): + changes = {"ref": "1234567890123456789012345"} + + # bandwidth_saver is on by default + self.assertTrue(self.app.config["BANDWIDTH_SAVER"]) + r = self.perform_put(changes) + self.assertFalse("ref" in r) + db_value = self.compare_put_with_get(self.app.config["ETAG"], r) + self.assertEqual(db_value, r[self.app.config["ETAG"]]) + self.item_etag = r[self.app.config["ETAG"]] + + # test return all fields (bandwidth_saver off) + self.app.config["BANDWIDTH_SAVER"] = False + r = self.perform_put(changes) + self.assertTrue("ref" in r) + db_value = self.compare_put_with_get(self.app.config["ETAG"], r) + self.assertEqual(db_value, r[self.app.config["ETAG"]]) + + def test_put_bandwidth_saver_credit_rule_broken(self): + _db = self.connection[MONGO_DBNAME] + rule = { + "amount": 300.0, + "duration": "months", + "name": "Testing BANDWIDTH_SAVER=False", + "start": "2020-03-28T06:00:00 UTC", + } + rule_id = _db.credit_rules.insert_one(rule).inserted_id + rule_url = "credit_rules/%s/" % (rule_id) + changes = { + "amount": 120.0, + "duration": "months", + "start": "2020-04-01T00:00:00 UTC", + } + response, _ = self.get("credit_rules/%s/" % (rule_id)) + etag = response[ETAG] + # bandwidth_saver is on by default + self.assertTrue(self.app.config["BANDWIDTH_SAVER"]) + self.assertTrue(self.app.config["PROJECTION"]) + r, status = self.put(rule_url, data=changes, headers=[("If-Match", etag)]) + self.assert200(status) + self.assertPutResponse(r, "%s" % (rule_id)) + self.assertFalse("amount" in r) + etag = r[self.app.config["ETAG"]] + r, _ = self.get(rule_url, "") + self.assertEqual(etag, r[self.app.config["ETAG"]]) + + # test return all fields (bandwidth_saver off) + self.app.config["BANDWIDTH_SAVER"] = False + changes["name"] = "Give it all to me!" + r, status = self.put(rule_url, data=changes, headers=[("If-Match", etag)]) + self.assert200(status) + self.assertPutResponse(r, "%s" % (rule_id)) + self.assertTrue( + all(["amount" in r, "duration" in r, "name" in r, "start" in r]), + 'One or more of "amount", "duration", "name", "start" is missing.', + ) + self.assertTrue(r["name"] == "Give it all to me!") + etag = r[self.app.config["ETAG"]] + r, status = self.get(rule_url, "") + self.assertEqual(etag, r[self.app.config["ETAG"]]) + + def test_put_dependency_fields_with_default(self): + # Test that if a dependency is missing but has a default value then the + # field is still accepted. See #353. + del self.domain["contacts"]["schema"]["ref"]["required"] + field = "dependency_field2" + test_value = "a value" + changes = {field: test_value} + r = self.perform_put(changes) + db_value = self.compare_put_with_get(field, r) + self.assertEqual(db_value, test_value) + + def test_put_dependency_fields_with_wrong_value(self): + # Test that if a dependency is not met, the put is refused + del self.domain["contacts"]["schema"]["ref"]["required"] + r, status = self.put( + self.item_id_url, + data={"dependency_field3": "value"}, + headers=[("If-Match", self.item_etag)], + ) + self.assert422(status) + r, status = self.put( + self.item_id_url, + data={"dependency_field1": "value", "dependency_field3": "value"}, + headers=[("If-Match", self.item_etag)], + ) + self.assert200(status) + + def test_put_custom_idfield(self): + product = {"title": "Awesome Hypercube"} + r, status = self.put("products/FOOBAR", data=product) + self.assert201(status) + + def test_put_internal(self): + # test that put_internal is available and working properly. + test_field = "ref" + test_value = "9876543210987654321098765" + data = {test_field: test_value} + with self.app.test_request_context(self.item_id_url): + r, _, _, status = put_internal( + self.known_resource, + data, + concurrency_check=False, + **{"_id": self.item_id} + ) + db_value = self.compare_put_with_get(test_field, r) + self.assertEqual(db_value, test_value) + self.assert200(status) + + def test_put_internal_skip_validation(self): + # test that put_internal is available and working properly. + test_field = "ref" + test_value = "9876543210987654321098765" + data = {test_field: test_value} + with self.app.test_request_context(self.item_id_url): + r, _, _, status = put_internal( + self.known_resource, + data, + concurrency_check=False, + skip_validation=True, + **{"_id": self.item_id} + ) + db_value = self.compare_put_with_get(test_field, r) + self.assertEqual(db_value, test_value) + self.assert200(status) + + def test_put_etag_header(self): + # test that Etag is always includer with response header. See #562. + changes = {"ref": "1234567890123456789012345"} + headers = [("Content-Type", "application/json"), ("If-Match", self.item_etag)] + r = self.test_client.put( + self.item_id_url, data=json.dumps(changes), headers=headers + ) + self.assertTrue("Etag" in r.headers) + + # test that ETag is compliant to RFC 7232-2.3 and #794 is fixed. + etag = r.headers["ETag"] + + self.assertTrue(etag[0] == '"') + self.assertTrue(etag[-1] == '"') + + def test_put_etag_header_enforce_ifmatch_disabled(self): + self.app.config["ENFORCE_IF_MATCH"] = False + changes = {"ref": "1234567890123456789012345"} + headers = [("Content-Type", "application/json"), ("If-Match", self.item_etag)] + r, status = self.put( + self.item_id_url, data=json.dumps(changes), headers=headers + ) + self.assertTrue(ETAG in r) + self.assertTrue(self.item_etag != r[ETAG]) + + def test_put_nested(self): + changes = { + "ref": "1234567890123456789012345", + "location.city": "a nested city", + "location.address": "a nested address", + } + r = self.perform_put(changes) + values = self.compare_put_with_get("location", r) + self.assertEqual(values["city"], "a nested city") + self.assertEqual(values["address"], "a nested address") + + def test_put_creates_unexisting_document(self): + id = str(ObjectId()) + url = "%s/%s" % (self.known_resource_url, id) + id_field = self.domain[self.known_resource]["id_field"] + changes = {"ref": "1234567890123456789012345"} + r, status = self.put(url, data=changes) + # 201 is a creation (POST) response + self.assert201(status) + # new document has id_field matching the PUT endpoint + self.assertEqual(r[id_field], str(id)) + + def test_put_returns_404_on_unexisting_document(self): + self.app.config["UPSERT_ON_PUT"] = False + id = str(ObjectId()) + url = "%s/%s" % (self.known_resource_url, id) + changes = {"ref": "1234567890123456789012345"} + r, status = self.put(url, data=changes) + self.assert404(status) + + def test_put_creates_unexisting_document_with_url_as_id(self): + id = str(ObjectId()) + url = "%s/%s" % (self.known_resource_url, id) + id_field = self.domain[self.known_resource]["id_field"] + changes = {"ref": "1234567890123456789012345", id_field: str(ObjectId())} + r, status = self.put(url, data=changes) + # 201 is a creation (POST) response + self.assert201(status) + # new document has id_field matching the PUT endpoint + # (eventual mismatching id_field in the payload is ignored/replaced) + self.assertEqual(r[id_field], str(id)) + + def test_put_creates_unexisting_document_fails_on_mismatching_id(self): + id = str(ObjectId()) + id_field = self.domain[self.known_resource]["id_field"] + changes = {"ref": "1234567890123456789012345", id_field: id} + r, status = self.put( + self.item_id_url, data=changes, headers=[("If-Match", self.item_etag)] + ) + self.assert400(status) + self.assertTrue("immutable" in r["_error"]["message"]) + + def test_put_type_coercion(self): + schema = self.domain[self.known_resource]["schema"] + schema["aninteger"]["coerce"] = lambda string: int(float(string)) + changes = {"ref": "1234567890123456789054321", "aninteger": "42.3"} + r, status = self.put( + self.item_id_url, data=changes, headers=[("If-Match", self.item_etag)] + ) + self.assert200(status) + r, status = self.get(r["_links"]["self"]["href"]) + self.assertEqual(r["aninteger"], 42) + + def perform_put(self, changes): + r, status = self.put( + self.item_id_url, data=changes, headers=[("If-Match", self.item_etag)] + ) + self.assert200(status) + self.assertPutResponse(r, self.item_id) + return r + + def assertPutResponse(self, response, item_id, resource=None): + id_field = self.domain[resource or self.known_resource]["id_field"] + self.assertTrue(STATUS in response) + self.assertTrue(STATUS_OK in response[STATUS]) + self.assertFalse(ISSUES in response) + self.assertTrue(id_field in response) + self.assertEqual(response[id_field], item_id) + self.assertTrue(LAST_UPDATED in response) + self.assertTrue(ETAG in response) + self.assertTrue("_links" in response) + self.assertItemLink(response["_links"], item_id) + + def compare_put_with_get(self, fields, put_response): + raw_r = self.test_client.get(self.item_id_url) + r, status = self.parse_response(raw_r) + self.assert200(status) + self.assertEqual(raw_r.headers.get("ETag").replace('"', ""), put_response[ETAG]) + if isinstance(fields, str): + return r[fields] + return [r[field] for field in fields] + + +class TestEvents(TestBase): + new_ref = "0123456789012345678901234" + + def test_on_pre_PUT(self): + devent = DummyEvent(self.before_replace) + self.app.on_pre_PUT += devent + self.put() + self.assertEqual(self.known_resource, devent.called[0]) + self.assertEqual(3, len(devent.called)) + + def test_on_pre_PUT_contacts(self): + devent = DummyEvent(self.before_replace) + self.app.on_pre_PUT_contacts += devent + self.put() + self.assertEqual(2, len(devent.called)) + + def test_on_pre_PUT_dynamic_filter(self): + def filter_this(resource, request, lookup): + lookup["_id"] = self.unknown_item_id + + self.app.on_pre_PUT += filter_this + # Would normally delete the known document; will return 404 instead. + r, s = self.parse_response(self.put()) + self.assert201(s) + + def test_on_post_PUT(self): + devent = DummyEvent(self.after_replace) + self.app.on_post_PUT += devent + self.put() + self.assertEqual(self.known_resource, devent.called[0]) + self.assertEqual(200, devent.called[2].status_code) + self.assertEqual(3, len(devent.called)) + + def test_on_post_PUT_contacts(self): + devent = DummyEvent(self.after_replace) + self.app.on_post_PUT_contacts += devent + self.put() + self.assertEqual(200, devent.called[1].status_code) + self.assertEqual(2, len(devent.called)) + + def test_on_replace(self): + devent = DummyEvent(self.before_replace) + self.app.on_replace += devent + self.put() + self.assertEqual(self.known_resource, devent.called[0]) + self.assertEqual(self.new_ref, devent.called[1]["ref"]) + self.assertEqual(3, len(devent.called)) + + def test_on_replace_contacts(self): + devent = DummyEvent(self.before_replace) + self.app.on_replace_contacts += devent + self.put() + self.assertEqual(self.new_ref, devent.called[0]["ref"]) + self.assertEqual(2, len(devent.called)) + + def test_on_replaced(self): + devent = DummyEvent(self.after_replace) + self.app.on_replaced += devent + self.put() + self.assertEqual(self.known_resource, devent.called[0]) + self.assertEqual(self.new_ref, devent.called[1]["ref"]) + self.assertEqual(3, len(devent.called)) + + def test_on_replaced_contacts(self): + devent = DummyEvent(self.after_replace) + self.app.on_replaced_contacts += devent + self.put() + self.assertEqual(self.new_ref, devent.called[0]["ref"]) + self.assertEqual(2, len(devent.called)) + + def before_replace(self): + db = self.connection[MONGO_DBNAME] + contact = db.contacts.find_one(ObjectId(self.item_id)) + return contact["ref"] == self.item_name + + def after_replace(self): + return not self.before_replace() + + def put(self): + headers = [("Content-Type", "application/json"), ("If-Match", self.item_etag)] + data = json.dumps({"ref": self.new_ref}) + return self.test_client.put(self.item_id_url, data=data, headers=headers) diff --git a/eve/tests/methods/ratelimit.py b/tests/methods/ratelimit.py similarity index 60% rename from eve/tests/methods/ratelimit.py rename to tests/methods/ratelimit.py index 3b7c62753..1049c544c 100644 --- a/eve/tests/methods/ratelimit.py +++ b/tests/methods/ratelimit.py @@ -1,12 +1,14 @@ -from eve.tests import TestBase import time +from tests import TestBase + class TestRateLimit(TestBase): def setUp(self): - super(TestRateLimit, self).setUp() + super().setUp() try: - from redis import Redis, ConnectionError + from redis import ConnectionError, Redis + self.app.redis = Redis() try: self.app.redis.flushdb() @@ -16,10 +18,10 @@ def setUp(self): self.app.redis = None if self.app.redis: - self.app.config['RATE_LIMIT_GET'] = (1, 1) + self.app.config["RATE_LIMIT_GET"] = (1, 1) def test_ratelimit_home(self): - self.get_ratelimit("/") + self.get_ratelimit("/") def test_ratelimit_resource(self): self.get_ratelimit(self.known_resource_url) @@ -28,21 +30,21 @@ def test_ratelimit_item(self): self.get_ratelimit(self.item_id_url) def test_noratelimits(self): - self.app.config['RATE_LIMIT_GET'] = None + self.app.config["RATE_LIMIT_GET"] = None if self.app.redis: self.app.redis.flushdb() r = self.test_client.get("/") self.assert200(r.status_code) - self.assertTrue('X-RateLimit-Remaining' not in r.headers) - self.assertTrue('X-RateLimit-Limit' not in r.headers) - self.assertTrue('X-RateLimit-Reset' not in r.headers) + self.assertTrue("X-RateLimit-Remaining" not in r.headers) + self.assertTrue("X-RateLimit-Limit" not in r.headers) + self.assertTrue("X-RateLimit-Reset" not in r.headers) def get_ratelimit(self, url): if self.app.redis: # we want the following two GET to be executed within the same # tick (1 second) t1, t2 = 1, 2 - while (t1 != t2): + while t1 != t2: t1 = int(time.time()) r1 = self.test_client.get(url) t2 = int(time.time()) @@ -50,19 +52,17 @@ def get_ratelimit(self, url): if t1 != t2: time.sleep(1) self.assertRateLimit(r1) - self.assertEqual(r2.status_code, 429) - self.assertTrue(b'Rate limit exceeded' in r2.get_data()) + self.assert429(r2.status_code) time.sleep(1) self.assertRateLimit(self.test_client.get(url)) else: - print("Skipped. Needs a running redis-server and 'pip install " - "redis'") + print("Skipped. Needs a running redis-server and 'pip install " "redis'") def assertRateLimit(self, r): - self.assertTrue('X-RateLimit-Remaining' in r.headers) - self.assertEqual(r.headers['X-RateLimit-Remaining'], '0') - self.assertTrue('X-RateLimit-Limit' in r.headers) - self.assertEqual(r.headers['X-RateLimit-Limit'], '1') + self.assertTrue("X-RateLimit-Remaining" in r.headers) + self.assertEqual(r.headers["X-RateLimit-Remaining"], "0") + self.assertTrue("X-RateLimit-Limit" in r.headers) + self.assertEqual(r.headers["X-RateLimit-Limit"], "1") # renouncing on testing the actual Reset value: - self.assertTrue('X-RateLimit-Reset' in r.headers) + self.assertTrue("X-RateLimit-Reset" in r.headers) diff --git a/tests/renders.py b/tests/renders.py new file mode 100644 index 000000000..bd3d46b93 --- /dev/null +++ b/tests/renders.py @@ -0,0 +1,377 @@ +# -*- coding: utf-8 -*- + +import simplejson as json +from bson import ObjectId + +from eve.utils import api_prefix + +from . import TestBase +from .test_settings import MONGO_DBNAME + + +class TestRenders(TestBase): + def test_default_render(self): + r = self.test_client.get("/") + self.assertEqual(r.content_type, "application/json") + + def test_json_render(self): + r = self.test_client.get("/", headers=[("Accept", "application/json")]) + self.assertEqual(r.content_type, "application/json") + + def test_xml_render(self): + r = self.test_client.get("/", headers=[("Accept", "application/xml")]) + self.assertTrue("application/xml" in r.content_type) + + def test_xml_url_escaping(self): + r = self.test_client.get( + "%s?max_results=1" % self.known_resource_url, + headers=[("Accept", "application/xml")], + ) + self.assertTrue(b"&" in r.get_data()) + + def test_xml_leaf_escaping(self): + # test that even xml leaves content is being properly escaped + + # We need to assign a `person` to our test invoice + _db = self.connection[MONGO_DBNAME] + fake_contact = self.random_contacts(1)[0] + fake_contact["ref"] = "12345 & 67890" + fake_contact_id = _db.contacts.insert_one(fake_contact).inserted_id + + r = self.test_client.get( + "%s/%s" % (self.known_resource_url, fake_contact_id), + headers=[("Accept", "application/xml")], + ) + self.assertTrue(b"12345 & 6789" in r.get_data()) + + def test_xml_ordered_nodes(self): + """Test that xml nodes are ordered and #441 is addressed.""" + r = self.test_client.get( + "%s?max_results=1" % self.known_resource_url, + headers=[("Accept", "application/xml")], + ) + data = r.get_data() + idx1 = data.index(b"_created") + idx2 = data.index(b"_etag") + idx3 = data.index(b"_id") + idx4 = data.index(b"_updated") + self.assertTrue(idx1 < idx2 < idx3 < idx4) + idx1 = data.index(b"max_results") + idx2 = data.index(b"page") + idx3 = data.index(b"total") + self.assertTrue(idx1 < idx2 < idx3) + idx1 = data.index(b"last") + idx2 = data.index(b"next") + idx3 = data.index(b"parent") + self.assertTrue(idx1 < idx2 < idx3) + + def test_xml_data_relation_hateoas(self): + # We need to assign a `person` to our test invoice + _db = self.connection[MONGO_DBNAME] + + fake_contact = self.random_contacts(1)[0] + fake_contact_id = _db.contacts.insert_one(fake_contact).inserted_id + url = self.domain[self.known_resource]["url"] + item_title = self.domain[self.known_resource]["item_title"] + invoices = self.domain["invoices"] + + # Test object id data relation fields + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, {"$set": {"person": fake_contact_id}} + ) + + r = self.test_client.get( + "%s/%s" % (invoices["url"], self.invoice_id), + headers=[("Accept", "application/xml")], + ) + data_relation_opening_tag = '' % ( + url, + fake_contact_id, + item_title, + ) + self.assertTrue(data_relation_opening_tag in r.data.decode()) + + def test_unknown_render(self): + r = self.test_client.get("/", headers=[("Accept", "application/html")]) + self.assertEqual(r.content_type, "application/json") + + def test_json_xml_disabled(self): + self.app.config["RENDERERS"] = tuple() + r = self.test_client.get( + self.known_resource_url, headers=[("Accept", "application/json")] + ) + self.assert500(r.status_code) + r = self.test_client.get( + self.known_resource_url, headers=[("Accept", "application/xml")] + ) + self.assert500(r.status_code) + r = self.test_client.get(self.known_resource_url) + self.assert500(r.status_code) + + def test_json_disabled(self): + self.app.config["RENDERERS"] = ("eve.render.XMLRenderer",) + r = self.test_client.get( + self.known_resource_url, headers=[("Accept", "application/json")] + ) + self.assertTrue("application/xml" in r.content_type) + r = self.test_client.get( + self.known_resource_url, headers=[("Accept", "application/xml")] + ) + self.assertTrue("application/xml" in r.content_type) + r = self.test_client.get(self.known_resource_url) + self.assertTrue("application/xml" in r.content_type) + + def test_xml_disabled(self): + self.app.config["RENDERERS"] = ("eve.render.JSONRenderer",) + r = self.test_client.get( + self.known_resource_url, headers=[("Accept", "application/xml")] + ) + self.assertEqual(r.content_type, "application/json") + r = self.test_client.get( + self.known_resource_url, headers=[("Accept", "application/json")] + ) + self.assertEqual(r.content_type, "application/json") + r = self.test_client.get(self.known_resource_url) + self.assertEqual(r.content_type, "application/json") + + def test_json_keys_sorted(self): + self.app.config["JSON_SORT_KEYS"] = True + r = self.test_client.get( + self.known_resource_url, headers=[("Accept", "application/json")] + ) + self.assertEqual( + json.dumps(json.loads(r.get_data()), sort_keys=True).encode(), r.get_data() + ) + + def test_jsonp_enabled(self): + arg = "callback" + self.app.config["JSONP_ARGUMENT"] = arg + val = "JSON_CALLBACK" + r = self.test_client.get("/?%s=%s" % (arg, val)) + self.assertTrue(r.get_data().decode("utf-8").startswith(val)) + + def test_CORS(self): + # no CORS headers if Origin is not provided with the request. + r = self.test_client.get("/") + self.assertFalse("Access-Control-Allow-Origin" in r.headers) + self.assertFalse("Access-Control-Allow-Methods" in r.headers) + self.assertFalse("Access-Control-Max-Age" in r.headers) + self.assertFalse("Access-Control-Expose-Headers" in r.headers) + self.assertFalse("Access-Control-Allow-Credentials" in r.headers) + self.assert200(r.status_code) + + # test that if X_DOMAINS is set to '*', then any Origin value is + # allowed. Also test that only the Origin header included with the + # request will be returned to the client. + self.app.config["X_DOMAINS"] = "*" + r = self.test_client.get("/", headers=[("Origin", "http://example.com")]) + self.assert200(r.status_code) + self.assertEqual(r.headers["Access-Control-Allow-Origin"], "http://example.com") + self.assertEqual(r.headers["Vary"], "Origin") + + # Given that CORS is activated with X_DOMAINS = '*', + # test that if X_ALLOW_CREDENTIALS is set to True + # then the relevant header is included in the response + self.app.config["X_ALLOW_CREDENTIALS"] = True + r = self.test_client.get("/", headers=[("Origin", "http://example.com")]) + self.assert200(r.status_code) + self.assertEqual(r.headers["Access-Control-Allow-Credentials"], "true") + + # with any other non-True value, it is missing + self.app.config["X_ALLOW_CREDENTIALS"] = False + r = self.test_client.get("/", headers=[("Origin", "http://example.com")]) + self.assert200(r.status_code) + self.assertFalse("Access-Control-Allow-Credentials" in r.headers) + + # test that if a list is set for X_DOMAINS, then: + # 1. only list values are accepted; + # 2. only the value included with the request is returned back. + self.app.config["X_DOMAINS"] = ["http://1of2.com", "http://2of2.com"] + r = self.test_client.get("/", headers=[("Origin", "http://1of2.com")]) + self.assert200(r.status_code) + self.assertEqual(r.headers["Access-Control-Allow-Origin"], "http://1of2.com") + + r = self.test_client.get("/", headers=[("Origin", "http://2of2.com")]) + self.assert200(r.status_code) + self.assertEqual(r.headers["Access-Control-Allow-Origin"], "http://2of2.com") + + r = self.test_client.get("/", headers=[("Origin", "http://notreally.com")]) + self.assert200(r.status_code) + self.assertEqual(r.headers["Access-Control-Allow-Origin"], "") + + # other Access-Control-Allow- headers are included. + self.assertTrue("Access-Control-Allow-Headers" in r.headers) + self.assertTrue("Access-Control-Allow-Methods" in r.headers) + self.assertTrue("Access-Control-Max-Age" in r.headers) + self.assertTrue("Access-Control-Expose-Headers" in r.headers) + + # unescaped dots of old (pre v0.7) or malicious X_DOMAINS definitions + # would be interpreted as any character, causing security issue with + # bad guy registering wwwxgithub.com to pass as www.github.com (see + # #660). + + self.app.config["X_DOMAINS"] = ["http://www.github.com"] + r = self.test_client.get("/", headers=[("Origin", "http://wwwxgithub.com")]) + self.assert200(r.status_code) + self.assertFalse( + "http://wwwxgithub.com" in r.headers["Access-Control-Allow-Origin"] + ) + + # test that X_DOMAINS does not match + # if the origin contains extra characters (#974) + r = self.test_client.get("/", headers=[("Origin", "http://1of2.com:8000")]) + self.assert200(r.status_code) + self.assertEqual(r.headers["Access-Control-Allow-Origin"], "") + + def test_CORS_regex(self): + # test if X_DOMAINS_RE is set with a list of regexes, + # origins are matched against this list (#974) + self.app.config["X_DOMAINS_RE"] = [r"^http://sub-\d{3}\.domain\.com$"] + + r = self.test_client.get("/", headers=[("Origin", "http://sub-123.domain.com")]) + self.assert200(r.status_code) + self.assertEqual( + r.headers["Access-Control-Allow-Origin"], "http://sub-123.domain.com" + ) + + # test that similar domains are not allowed + r = self.test_client.get( + "/", headers=[("Origin", "http://sub-1234.domain.com")] + ) + self.assert200(r.status_code) + self.assertEqual(r.headers["Access-Control-Allow-Origin"], "") + + r = self.test_client.get( + "/", headers=[("Origin", "http://sub-123.domain.com:8000")] + ) + self.assert200(r.status_code) + self.assertEqual(r.headers["Access-Control-Allow-Origin"], "") + + r = self.test_client.get("/", headers=[("Origin", "http://sub-123xdomain.com")]) + self.assert200(r.status_code) + self.assertEqual(r.headers["Access-Control-Allow-Origin"], "") + + # test that invalid regexes are ignored, especially '*' + self.app.config["X_DOMAINS_RE"] = ["*"] + r = self.test_client.get("/", headers=[("Origin", "http://www.example.com")]) + self.assert200(r.status_code) + self.assertEqual(r.headers["Access-Control-Allow-Origin"], "") + + def test_CORS_MAX_AGE(self): + self.app.config["X_DOMAINS"] = "*" + r = self.test_client.get("/", headers=[("Origin", "http://example.com")]) + self.assertEqual(r.headers["Access-Control-Max-Age"], "21600") + + self.app.config["X_MAX_AGE"] = 2000 + r = self.test_client.get("/", headers=[("Origin", "http://example.com")]) + self.assertEqual(r.headers["Access-Control-Max-Age"], "2000") + + def test_CORS_OPTIONS(self, url="/", methods=None): + if methods is None: + methods = [] + + r = self.test_client.open(url, method="OPTIONS") + self.assertFalse("Access-Control-Allow-Origin" in r.headers) + self.assertFalse("Access-Control-Allow-Methods" in r.headers) + self.assertFalse("Access-Control-Max-Age" in r.headers) + self.assertFalse("Access-Control-Expose-Headers" in r.headers) + self.assertFalse("Access-Control-Allow-Credentials" in r.headers) + self.assert200(r.status_code) + + # test that if X_DOMAINS is set to '*', then any Origin value is + # allowed. Also test that only the Origin header included with the + # request will be # returned back to the client. + self.app.config["X_DOMAINS"] = "*" + r = self.test_client.open( + url, method="OPTIONS", headers=[("Origin", "http://example.com")] + ) + self.assert200(r.status_code) + self.assertEqual(r.headers["Access-Control-Allow-Origin"], "http://example.com") + self.assertEqual(r.headers["Vary"], "Origin") + for m in methods: + self.assertTrue(m in r.headers["Access-Control-Allow-Methods"]) + + # Given that CORS is activated with X_DOMAINS = '*' + # test that if X_ALLOW_CREDENTIALS is set to True + # then the relevant header is included in the response + self.app.config["X_ALLOW_CREDENTIALS"] = True + r = self.test_client.open( + url, method="OPTIONS", headers=[("Origin", "http://example.com")] + ) + self.assert200(r.status_code) + self.assertEqual(r.headers["Access-Control-Allow-Credentials"], "true") + + # with any other non-True value, it is missing + self.app.config["X_ALLOW_CREDENTIALS"] = False + r = self.test_client.open( + url, method="OPTIONS", headers=[("Origin", "http://example.com")] + ) + self.assert200(r.status_code) + self.assertFalse("Access-Control-Allow-Credentials" in r.headers) + + self.app.config["X_DOMAINS"] = ["http://1of2.com", "http://2of2.com"] + r = self.test_client.open( + url, method="OPTIONS", headers=[("Origin", "http://1of2.com")] + ) + self.assert200(r.status_code) + self.assertEqual(r.headers["Access-Control-Allow-Origin"], "http://1of2.com") + r = self.test_client.open( + url, method="OPTIONS", headers=[("Origin", "http://2of2.com")] + ) + self.assert200(r.status_code) + self.assertEqual(r.headers["Access-Control-Allow-Origin"], "http://2of2.com") + + for m in methods: + self.assertTrue(m in r.headers["Access-Control-Allow-Methods"]) + + self.assertTrue("Access-Control-Allow-Origin" in r.headers) + self.assertTrue("Access-Control-Max-Age" in r.headers) + self.assertTrue("Access-Control-Expose-Headers" in r.headers) + + r = self.test_client.get(url, headers=[("Origin", "http://not_an_example.com")]) + self.assert200(r.status_code) + self.assertEqual(r.headers["Access-Control-Allow-Origin"], "") + for m in methods: + self.assertTrue(m in r.headers["Access-Control-Allow-Methods"]) + + def test_CORS_OPTIONS_resources(self): + prefix = api_prefix( + self.app.config["URL_PREFIX"], self.app.config["API_VERSION"] + ) + + del self.domain["peopleinvoices"] + del self.domain["peoplerequiredinvoices"] + del self.domain["peoplesearches"] + del self.domain["internal_transactions"] + del self.domain["child_products"] + for _, settings in self.app.config["DOMAIN"].items(): + # resource endpoint + url = "%s/%s/" % (prefix, settings["url"]) + methods = settings["resource_methods"] + ["OPTIONS"] + self.test_CORS_OPTIONS(url, methods) + + def test_CORS_OPTIONS_item(self): + prefix = api_prefix( + self.app.config["URL_PREFIX"], self.app.config["API_VERSION"] + ) + + url = "%s%s" % (prefix, self.item_id_url) + methods = self.domain[self.known_resource]["resource_methods"] + ["OPTIONS"] + self.test_CORS_OPTIONS(url, methods) + url = "%s%s/%s" % (prefix, self.known_resource_url, self.item_ref) + methods = ["GET", "OPTIONS"] + + def test_CORS_OPTIONS_schema(self): + """Test that CORS is also supported at SCHEMA_ENDPOINT""" + self.app.config["SCHEMA_ENDPOINT"] = "schema" + self.app._init_schema_endpoint() + methods = ["GET", "OPTIONS"] + self.test_CORS_OPTIONS("schema", methods) + + def test_deprecated_renderers_supports_py27(self): + """Make sure #1175 is fixed""" + self.app.config["RENDERES"] = False + try: + self.app.check_deprecated_features() + except AttributeError: + self.fail("AttributeError raised unexpectedly.") diff --git a/tests/response.py b/tests/response.py new file mode 100644 index 000000000..07c558675 --- /dev/null +++ b/tests/response.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- + +import os +from ast import literal_eval + +import simplejson as json + +import eve + +from . import TestBase + + +class TestResponse(TestBase): + def setUp(self): + super().setUp() + self.r = self.test_client.get("/%s/" % self.empty_resource) + + def test_response_data(self): + response = None + try: + response = literal_eval(self.r.get_data().decode()) + except Exception: + self.fail("standard response cannot be converted to a dict") + self.assertTrue(isinstance(response, dict)) + + def test_response_object(self): + response = literal_eval(self.r.get_data().decode()) + self.assertTrue(isinstance(response, dict)) + self.assertEqual(len(response), 3) + + resource = response.get("_items") + self.assertTrue(isinstance(resource, list)) + links = response.get("_links") + self.assertTrue(isinstance(links, dict)) + meta = response.get("_meta") + self.assertTrue(isinstance(meta, dict)) + + def test_response_pretty(self): + # check if pretty printing was successful by checking the length of the + # response since pretty printing the respone makes it longer and not + # type dict anymore + self.r = self.test_client.get("/%s/?pretty" % self.empty_resource) + response = self.r.get_data().decode() + self.assertEqual(len(response), 300) + + +class TestNoHateoas(TestBase): + def setUp(self): + super().setUp() + self.app.config["HATEOAS"] = False + self.domain[self.known_resource]["hateoas"] = False + + def test_get_no_hateoas_resource(self): + r = self.test_client.get(self.known_resource_url) + response = json.loads(r.get_data().decode()) + self.assertTrue(isinstance(response, dict)) + self.assertEqual(len(response["_items"]), 25) + item = response["_items"][0] + self.assertTrue(isinstance(item, dict)) + self.assertTrue("_links" not in response) + + def test_get_no_hateoas_item(self): + r = self.test_client.get(self.item_id_url) + response = json.loads(r.get_data().decode()) + self.assertTrue(isinstance(response, dict)) + self.assertTrue("_links" not in response) + + def test_get_no_hateoas_homepage(self): + r = self.test_client.get("/") + self.assert200(r.status_code) + + def test_get_no_hateoas_homepage_reply(self): + r = self.test_client.get("/") + resp = json.loads(r.get_data().decode()) + self.assertEqual(resp, {}) + + self.app.config["INFO"] = "_info" + + r = self.test_client.get("/") + resp = json.loads(r.get_data().decode()) + self.assertEqual(resp["_info"]["server"], "Eve") + self.assertEqual(resp["_info"]["version"], eve.__version__) + + settings_file = os.path.join(self.this_directory, "test_version.py") + self.app = eve.Eve(settings=settings_file) + self.app.config["INFO"] = "_info" + + r = self.app.test_client().get("/v1") + resp = json.loads(r.get_data().decode()) + self.assertEqual(resp["_info"]["api_version"], self.app.config["API_VERSION"]) + self.assertEqual(resp["_info"]["server"], "Eve") + self.assertEqual(resp["_info"]["version"], eve.__version__) + + def test_post_no_hateoas(self): + data = {"item1": json.dumps({"ref": "1234567890123456789054321"})} + headers = [("Content-Type", "application/x-www-form-urlencoded")] + r = self.test_client.post(self.known_resource_url, data=data, headers=headers) + response = json.loads(r.get_data().decode()) + self.assertTrue("_links" not in response) + + def test_patch_no_hateoas(self): + data = {"item1": json.dumps({"ref": "0000000000000000000000000"})} + headers = [ + ("Content-Type", "application/x-www-form-urlencoded"), + ("If-Match", self.item_etag), + ] + r = self.test_client.patch(self.item_id_url, data=data, headers=headers) + response = json.loads(r.get_data().decode()) + self.assertTrue("_links" not in response) diff --git a/tests/suite_generator.py b/tests/suite_generator.py new file mode 100644 index 000000000..ec1c0dd2d --- /dev/null +++ b/tests/suite_generator.py @@ -0,0 +1,7 @@ +from datetime import datetime + + +class EmbeddedDoc: + def __init__(self, _id): + self._id = _id + self._created = datetime.utcnow() diff --git a/eve/tests/test.db b/tests/test.db similarity index 100% rename from eve/tests/test.db rename to tests/test.db diff --git a/eve/tests/methods/__init__.py b/tests/test_io/__init__.py similarity index 100% rename from eve/tests/methods/__init__.py rename to tests/test_io/__init__.py diff --git a/tests/test_io/flask_pymongo.py b/tests/test_io/flask_pymongo.py new file mode 100644 index 000000000..77101e63d --- /dev/null +++ b/tests/test_io/flask_pymongo.py @@ -0,0 +1,83 @@ +import pytest +from pymongo import MongoClient +from pymongo.errors import OperationFailure + +from eve.io.mongo.flask_pymongo import PyMongo +from tests import TestBase +from tests.test_settings import ( + MONGO1_DBNAME, + MONGO1_PASSWORD, + MONGO1_USERNAME, + MONGO_HOST, + MONGO_PORT, +) + + +class TestPyMongo(TestBase): + def setUp(self, url_converters=None): + super().setUp(url_converters) + self._setupdb() + schema = {"title": {"type": "string"}} + settings = {"schema": schema, "mongo_prefix": "MONGO1"} + + self.app.register_resource("works", settings) + + def test_auth_params_provided_in_mongo_url(self): + self.app.config["MONGO1_URL"] = "mongodb://%s:%s@%s:%s" % ( + MONGO1_USERNAME, + MONGO1_PASSWORD, + MONGO_HOST, + MONGO_PORT, + ) + with self.app.app_context(): + db = PyMongo(self.app, "MONGO1").db + self.assertEqual(0, db.works.count_documents({})) + + def test_auth_params_provided_in_config(self): + self.app.config["MONGO1_USERNAME"] = MONGO1_USERNAME + self.app.config["MONGO1_PASSWORD"] = MONGO1_PASSWORD + with self.app.app_context(): + db = PyMongo(self.app, "MONGO1").db + self.assertEqual(0, db.works.count_documents({})) + + def test_invalid_auth_params_provided(self): + # if bad username and/or password is provided in MONGO_URL and mongo + # run w\o --auth pymongo won't raise exception + def func(): + with self.app.app_context(): + db = PyMongo(self.app, "MONGO1").db + db.works.find_one() + + self.app.config["MONGO1_USERNAME"] = "bad_username" + self.app.config["MONGO1_PASSWORD"] = "bad_password" + self.assertRaises(OperationFailure, func) + + def test_invalid_port(self): + self.app.config["MONGO1_PORT"] = "bad_value" + self.assertRaises(TypeError, self._pymongo_instance) + + def test_invalid_options(self): + self.app.config["MONGO1_OPTIONS"] = {"connectTimeoutMS": "bad_value"} + self.assertRaises(ValueError, self._pymongo_instance) + + def test_valid_port(self): + self.app.config["MONGO1_PORT"] = 27017 + with self.app.app_context(): + db = PyMongo(self.app, "MONGO1").db + self.assertEqual(0, db.works.count_documents({})) + + def _setupdb(self): + self.connection = MongoClient() + self.connection.drop_database(MONGO1_DBNAME) + db = self.connection[MONGO1_DBNAME] + try: + db.command("dropUser", MONGO1_USERNAME) + except OperationFailure: + pass + db.command( + "createUser", MONGO1_USERNAME, pwd=MONGO1_PASSWORD, roles=["dbAdmin"] + ) + + def _pymongo_instance(self): + with self.app.app_context(): + PyMongo(self.app, "MONGO1") diff --git a/tests/test_io/media.py b/tests/test_io/media.py new file mode 100644 index 000000000..9c76b217a --- /dev/null +++ b/tests/test_io/media.py @@ -0,0 +1,505 @@ +import base64 +from io import BytesIO +from unittest import TestCase + +from bson import ObjectId + +from eve import ETAG, ISSUES, STATUS, STATUS_ERR, STATUS_OK +from eve.io.media import MediaStorage +from eve.io.mongo import GridFSMediaStorage +from tests import MONGO_DBNAME, TestBase + + +class TestMediaStorage(TestCase): + def test_base_media_storage(self): + a = MediaStorage() + self.assertEqual(a.app, None) + + a = MediaStorage("hello") + self.assertEqual(a.app, "hello") + + self.assertRaises(NotImplementedError, a.get, 1) + self.assertRaises(NotImplementedError, a.put, "clean", "filename") + self.assertRaises(NotImplementedError, a.delete, 1) + self.assertRaises(NotImplementedError, a.exists, 1) + + +class TestGridFSMediaStorage(TestBase): + def setUp(self): + super().setUp() + self.url = self.known_resource_url + self.resource = self.known_resource + self.headers = [("Content-Type", "multipart/form-data")] + self.id_field = self.domain[self.resource]["id_field"] + self.test_field, self.test_value = "ref", "1234567890123456789054321" + self.clean = b"my file contents" + self.encoded = base64.b64encode(self.clean).decode() + + def test_gridfs_media_storage_errors(self): + self.assertRaises(TypeError, GridFSMediaStorage) + self.assertRaises(TypeError, GridFSMediaStorage, "hello") + + def test_gridfs_media_storage_post(self): + # send something different than a file and get an error back + data = {"media": "not a file"} + r, s = self.parse_response( + self.test_client.post(self.url, data=data, headers=self.headers) + ) + self.assertEqual(STATUS_ERR, r[STATUS]) + + # validates media fields + self.assertTrue("must be of media type" in r[ISSUES]["media"]) + # also validates ordinary fields + self.assertTrue("required" in r[ISSUES][self.test_field]) + + r, s = self._post() + self.assertEqual(STATUS_OK, r[STATUS]) + + # compare original and returned data + _id = r[self.id_field] + self.assertMediaField(_id, self.encoded, self.clean) + + # GET the file at the resource endpoint + where = 'where={"%s": "%s"}' % (self.id_field, _id) + r, s = self.parse_response(self.test_client.get("%s?%s" % (self.url, where))) + self.assertEqual(len(r["_items"]), 1) + returned = r["_items"][0]["media"] + + # returned value is a base64 encoded string + self.assertEqual(returned, self.encoded) + + # which decodes to the original clean + self.assertEqual(base64.b64decode(returned.encode()), self.clean) + + def test_gridfs_media_storage_post_excluded_file_in_result(self): + # send something different than a file and get an error back + data = {"media": "not a file"} + r, s = self.parse_response( + self.test_client.post(self.url, data=data, headers=self.headers) + ) + self.assertEqual(STATUS_ERR, r[STATUS]) + + # validates media fields + self.assertTrue("must be of media type" in r[ISSUES]["media"]) + # also validates ordinary fields + self.assertTrue("required" in r[ISSUES][self.test_field]) + + r, s = self._post() + self.assertEqual(STATUS_OK, r[STATUS]) + + self.app.config["RETURN_MEDIA_AS_BASE64_STRING"] = False + # compare original and returned data + _id = r[self.id_field] + + # GET the file at the resource endpoint + where = 'where={"%s": "%s"}' % (self.id_field, _id) + r, s = self.parse_response(self.test_client.get("%s?%s" % (self.url, where))) + self.assertEqual(len(r["_items"]), 1) + returned = r["_items"][0]["media"] + + # returned value is a base64 encoded string + self.assertEqual(returned, None) + + def test_gridfs_media_storage_post_extended(self): + r, s = self._post() + self.assertEqual(STATUS_OK, r[STATUS]) + + # request extended format file response + self.app.config["EXTENDED_MEDIA_INFO"] = ["content_type", "length"] + + # compare original and returned data + _id = r[self.id_field] + self.assertMediaFieldExtended(_id, self.encoded, self.clean) + + # GET the file at the resource endpoint + where = 'where={"%s": "%s"}' % (self.id_field, _id) + r, s = self.parse_response(self.test_client.get("%s?%s" % (self.url, where))) + self.assertEqual(len(r["_items"]), 1) + returned = r["_items"][0]["media"] + + # returned value is a base64 encoded string + self.assertEqual(returned["file"], self.encoded) + + # which decodes to the original clean + self.assertEqual(base64.b64decode(returned["file"].encode()), self.clean) + + # also verify our extended fields + self.assertEqual(returned["content_type"], "text/plain") + self.assertEqual(returned["length"], 16) + + def test_gridfs_media_storage_post_extended_excluded_file_in_result(self): + r, s = self._post() + self.assertEqual(STATUS_OK, r[STATUS]) + + # request extended format file response + self.app.config["EXTENDED_MEDIA_INFO"] = ["content_type", "length"] + self.app.config["RETURN_MEDIA_AS_BASE64_STRING"] = False + # compare original and returned data + _id = r[self.id_field] + + # GET the file at the resource endpoint + where = 'where={"%s": "%s"}' % (self.id_field, _id) + r, s = self.parse_response(self.test_client.get("%s?%s" % (self.url, where))) + self.assertEqual(len(r["_items"]), 1) + returned = r["_items"][0]["media"] + + # returned value is None + self.assertEqual(returned["file"], None) + + # also verify our extended fields + self.assertEqual(returned["content_type"], "text/plain") + self.assertEqual(returned["length"], 16) + + def test_gridfs_media_storage_put(self): + r, s = self._post() + _id = r[self.id_field] + etag = r[ETAG] + + # compare original and returned data + self.assertMediaField(_id, self.encoded, self.clean) + + with self.app.test_request_context(): + # retrieve media_id + media_id = self.assertMediaStored(_id) + + # PUT replaces the file with new one + clean = b"my new file contents" + encoded = base64.b64encode(clean).decode() + test_field, test_value = "ref", "9234567890123456789054321" + data = {"media": (BytesIO(clean), "test.txt"), test_field: test_value} + headers = [("Content-Type", "multipart/form-data"), ("If-Match", etag)] + + r, s = self.parse_response( + self.test_client.put( + ("%s/%s" % (self.url, _id)), data=data, headers=headers + ) + ) + self.assertEqual(STATUS_OK, r[STATUS]) + + with self.app.test_request_context(): + # media has been properly stored + self.assertMediaStored(_id) + + # compare original and returned data + r, s = self.assertMediaField(_id, encoded, clean) + + # and of course, the ordinary field has been updated too + self.assertEqual(r[test_field], test_value) + + with self.app.test_request_context(): + # previous media doesn't exist anymore (it's been deleted) + self.assertFalse(self.app.media.exists(media_id, self.resource)) + + def test_gridfs_media_storage_patch(self): + r, s = self._post() + _id = r[self.id_field] + etag = r[ETAG] + + # compare original and returned data + self.assertMediaField(_id, self.encoded, self.clean) + + with self.app.test_request_context(): + # retrieve media_id + media_id = self.assertMediaStored(_id) + + # PATCH replaces the file with new one + clean = b"my new file contents" + encoded = base64.b64encode(clean).decode() + test_field, test_value = "ref", "9234567890123456789054321" + data = {"media": (BytesIO(clean), "test.txt"), test_field: test_value} + headers = [("Content-Type", "multipart/form-data"), ("If-Match", etag)] + + r, s = self.parse_response( + self.test_client.patch( + ("%s/%s" % (self.url, _id)), data=data, headers=headers + ) + ) + self.assertEqual(STATUS_OK, r[STATUS]) + + # compare original and returned data + r, s = self.assertMediaField(_id, encoded, clean) + + # and of course, the ordinary field has been updated too + self.assertEqual(r[test_field], test_value) + + with self.app.test_request_context(): + # previous media doesn't exist anymore (it's been deleted) + self.assertFalse(self.app.media.exists(media_id, self.resource)) + + def test_gridfs_media_storage_patch_null(self): + # set 'media' field to 'nullable' + self.domain[self.known_resource]["schema"]["media"]["nullable"] = True + + response, status = self._post() + self.assert201(status) + + _id = response[self.id_field] + etag = response[ETAG] + + # test that nullable media field can be set to None + data = {"media": None} + headers = [("If-Match", etag)] + response, status = self.patch( + ("%s/%s" % (self.url, _id)), data=data, headers=headers + ) + self.assert200(status) + + response, status = self.get(self.known_resource, item=_id) + self.assert200(status) + self.assertEqual(response["media"], None) + + def test_gridfs_media_storage_delete(self): + r, s = self._post() + _id = r[self.id_field] + etag = r[ETAG] + + with self.app.test_request_context(): + # retrieve media_id and compare original and returned data + self.assertMediaField(_id, self.encoded, self.clean) + + media_id = self.assertMediaStored(_id) + + # DELETE deletes both the document and the media file + headers = [("If-Match", etag)] + + r, s = self.parse_response( + self.test_client.delete(("%s/%s" % (self.url, _id)), headers=headers) + ) + self.assert204(s) + + with self.app.test_request_context(): + # media doesn't exist anymore (it's been deleted) + self.assertFalse(self.app.media.exists(media_id, self.resource)) + + # GET returns 404 + r, s = self.parse_response(self.test_client.get("%s/%s" % (self.url, _id))) + self.assert404(s) + + def test_get_media_can_leverage_projection(self): + """Test that static projection expose fields other than media + and client projection on media will work. + """ + # post a document with *hiding media* + r, s = self._post_hide_media() + + _id = r[self.id_field] + + projection = '{"media": 1}' + response, status = self.parse_response( + self.test_client.get( + "%s/%s?projection=%s" + % (self.resource_exclude_media_url, _id, projection) + ) + ) + self.assert200(status) + + self.assertFalse("title" in response) + self.assertFalse("ref" in response) + # client-side projection should work + self.assertTrue("media" in response) + self.assertTrue(self.domain[self.known_resource]["id_field"] in response) + self.assertTrue(self.app.config["ETAG"] in response) + self.assertTrue(self.app.config["LAST_UPDATED"] in response) + self.assertTrue(self.app.config["DATE_CREATED"] in response) + self.assertTrue(r[self.app.config["LAST_UPDATED"]] != self.epoch) + self.assertTrue(r[self.app.config["DATE_CREATED"]] != self.epoch) + + response, status = self.parse_response( + self.test_client.get("%s/%s" % (self.resource_exclude_media_url, _id)) + ) + self.assert200(status) + + self.assertTrue("title" in response) + self.assertTrue("ref" in response) + # not shown without projection + self.assertFalse("media" in response) + self.assertTrue(self.domain[self.known_resource]["id_field"] in response) + self.assertTrue(self.app.config["ETAG"] in response) + self.assertTrue(self.app.config["LAST_UPDATED"] in response) + self.assertTrue(self.app.config["DATE_CREATED"] in response) + self.assertTrue(r[self.app.config["LAST_UPDATED"]] != self.epoch) + self.assertTrue(r[self.app.config["DATE_CREATED"]] != self.epoch) + + def test_gridfs_media_storage_delete_projection(self): + """test that #284 is fixed: If you have a media field, and set + datasource projection to 0 for that field, the media will not be + deleted + """ + r, s = self._post() + _id = r[self.id_field] + + with self.app.test_request_context(): + # retrieve media_id and compare original and returned data + media_id = self.assertMediaStored(_id) + + self.app.config["DOMAIN"]["contacts"]["datasource"]["projection"] = {"media": 0} + + r, s = self.parse_response(self.test_client.get("%s/%s" % (self.url, _id))) + etag = r[ETAG] + + # DELETE deletes both the document and the media file + headers = [("If-Match", etag)] + + r, s = self.parse_response( + self.test_client.delete(("%s/%s" % (self.url, _id)), headers=headers) + ) + self.assert204(s) + + with self.app.test_request_context(): + # media doesn't exist anymore (it's been deleted) + self.assertFalse(self.app.media.exists(media_id, self.resource)) + + # GET returns 404 + r, s = self.parse_response(self.test_client.get("%s/%s" % (self.url, _id))) + self.assert404(s) + + def test_gridfs_media_storage_return_url(self): + self.app._init_media_endpoint() + self.app.config["RETURN_MEDIA_AS_BASE64_STRING"] = False + self.app.config["RETURN_MEDIA_AS_URL"] = True + + r, s = self._post() + self.assertEqual(STATUS_OK, r[STATUS]) + _id = r[self.id_field] + + # GET the file at the resource endpoint + where = 'where={"%s": "%s"}' % (self.id_field, _id) + r, s = self.parse_response(self.test_client.get("%s?%s" % (self.url, where))) + self.assertEqual(len(r["_items"]), 1) + url = r["_items"][0]["media"] + + with self.app.test_request_context(): + media_id = self.assertMediaStored(_id) + + self.assertEqual("/media/%s" % media_id, url) + response = self.test_client.get(url) + self.assertEqual(self.clean, response.get_data()) + + def test_gridfs_partial_media(self): + self.app._init_media_endpoint() + self.app.config["RETURN_MEDIA_AS_BASE64_STRING"] = False + self.app.config["RETURN_MEDIA_AS_URL"] = True + + r, s = self._post() + _id = r[self.id_field] + where = 'where={"%s": "%s"}' % (self.id_field, _id) + r, s = self.parse_response(self.test_client.get("%s?%s" % (self.url, where))) + url = r["_items"][0]["media"] + + headers = {"Range": "bytes=0-5"} + response = self.test_client.get(url, headers=headers) + self.assertEqual(self.clean[:6], response.get_data()) + headers = {"Range": "bytes=5-10"} + response = self.test_client.get(url, headers=headers) + self.assertEqual(self.clean[5:11], response.get_data()) + headers = {"Range": "bytes=0-999"} + response = self.test_client.get(url, headers=headers) + self.assertEqual(self.clean, response.get_data()) + + def test_gridfs_media_storage_base_url(self): + self.app._init_media_endpoint() + self.app.config["RETURN_MEDIA_AS_BASE64_STRING"] = False + self.app.config["RETURN_MEDIA_AS_URL"] = True + self.app.config["MEDIA_BASE_URL"] = "http://s3-us-west-2.amazonaws.com" + self.app.config["MEDIA_ENDPOINT"] = "foo" + + r, s = self._post() + self.assertEqual(STATUS_OK, r[STATUS]) + _id = r[self.id_field] + + # GET the file at the resource endpoint + where = 'where={"%s": "%s"}' % (self.id_field, _id) + r, s = self.parse_response(self.test_client.get("%s?%s" % (self.url, where))) + self.assertEqual(len(r["_items"]), 1) + url = r["_items"][0]["media"] + + with self.app.test_request_context(): + media_id = self.assertMediaStored(_id) + self.assertEqual( + "%s/%s/%s" + % ( + self.app.config["MEDIA_BASE_URL"], + self.app.config["MEDIA_ENDPOINT"], + media_id, + ), + url, + ) + + def test_media_endpoint_supports_CORS(self): + self.app._init_media_endpoint() + self.app.config["RETURN_MEDIA_AS_BASE64_STRING"] = False + self.app.config["RETURN_MEDIA_AS_URL"] = True + self.app.config["X_DOMAINS"] = "*" + + r, s = self._post() + self.assertEqual(STATUS_OK, r[STATUS]) + _id = r[self.id_field] + + with self.app.test_request_context(): + media_id = self.assertMediaStored(_id) + + methods = ["GET", "OPTIONS"] + for method in methods: + r = self.test_client.get( + "/media/%s" % media_id, + method=method, + headers=[("Origin", "http://example.com")], + ) + self.assert200(r.status_code) + self.assertEqual( + r.headers["Access-Control-Allow-Origin"], "http://example.com" + ) + self.assertEqual(r.headers["Vary"], "Origin") + self.assertTrue(method in r.headers["Access-Control-Allow-Methods"]) + + def assertMediaField(self, _id, encoded, clean): + # GET the file at the item endpoint + r, s = self.parse_response(self.test_client.get("%s/%s" % (self.url, _id))) + returned = r["media"] + # returned value is a base64 encoded string + self.assertEqual(returned, encoded) + # which decodes to the original file clean + self.assertEqual(base64.b64decode(returned.encode()), clean) + return r, s + + def assertMediaFieldExtended(self, _id, encoded, clean): + # GET the file at the item endpoint + r, s = self.parse_response(self.test_client.get("%s/%s" % (self.url, _id))) + returned = r["media"]["file"] + # returned value is a base64 encoded string + self.assertEqual(returned, encoded) + # which decodes to the original file clean + self.assertEqual(base64.b64decode(returned.encode()), clean) + return r, s + + def assertMediaStored(self, _id): + _db = self.connection[MONGO_DBNAME] + + # retrieve media id + media_id = _db.contacts.find_one({self.id_field: ObjectId(_id)})["media"] + + # verify it's actually stored in the media storage system + self.assertTrue(self.app.media.exists(media_id, self.resource)) + return media_id + + def _post(self): + # send a file and a required, ordinary field with no issues + data = { + "media": (BytesIO(self.clean), "test.txt"), + self.test_field: self.test_value, + } + return self.parse_response( + self.test_client.post(self.url, data=data, headers=self.headers) + ) + + def _post_hide_media(self): + # send a file and a required, ordinary field with no issues + data = { + "media": (BytesIO(self.clean), "test.txt"), + self.test_field: self.test_value, + } + return self.parse_response( + self.test_client.post( + self.resource_exclude_media_url, data=data, headers=self.headers + ) + ) diff --git a/tests/test_io/mongo.py b/tests/test_io/mongo.py new file mode 100644 index 000000000..1d0d3cf8a --- /dev/null +++ b/tests/test_io/mongo.py @@ -0,0 +1,484 @@ +# -*- coding: utf-8 -*- +from datetime import datetime +from unittest import TestCase + +import simplejson as json +from bson import ObjectId, decimal128 +from bson.dbref import DBRef +from cerberus import SchemaError + +from eve.io.mongo import Mongo, MongoJSONEncoder, Validator +from eve.io.mongo.parser import ParseError, parse +from tests import TestBase +from tests.test_settings import MONGO_DBNAME + + +class TestPythonParser(TestCase): + def test_Eq(self): + r = parse('a == "whatever"') + self.assertEqual(type(r), dict) + self.assertEqual(r, {"a": "whatever"}) + + def test_Gt(self): + r = parse("a > 1") + self.assertEqual(type(r), dict) + self.assertEqual(r, {"a": {"$gt": 1}}) + + def test_GtE(self): + r = parse("a >= 1") + self.assertEqual(type(r), dict) + self.assertEqual(r, {"a": {"$gte": 1}}) + + def test_Lt(self): + r = parse("a < 1") + self.assertEqual(type(r), dict) + self.assertEqual(r, {"a": {"$lt": 1}}) + + def test_LtE(self): + r = parse("a <= 1") + self.assertEqual(type(r), dict) + self.assertEqual(r, {"a": {"$lte": 1}}) + + def test_NotEq(self): + r = parse("a != 1") + self.assertEqual(type(r), dict) + self.assertEqual(r, {"a": {"$ne": 1}}) + + def test_And_BoolOp(self): + r = parse("a == 1 and b == 2") + self.assertEqual(type(r), dict) + self.assertEqual(r, {"$and": [{"a": 1}, {"b": 2}]}) + + def test_Or_BoolOp(self): + r = parse("a == 1 or b == 2") + self.assertEqual(type(r), dict) + self.assertEqual(r, {"$or": [{"a": 1}, {"b": 2}]}) + + def test_nested_BoolOp(self): + r = parse("a == 1 or (b == 2 and c == 3)") + self.assertEqual(type(r), dict) + self.assertEqual(r, {"$or": [{"a": 1}, {"$and": [{"b": 2}, {"c": 3}]}]}) + + def test_ObjectId_Call(self): + r = parse('_id == ObjectId("4f4644fbc88e20212c000000")') + self.assertEqual(type(r), dict) + self.assertEqual(r, {"_id": ObjectId("4f4644fbc88e20212c000000")}) + + def test_datetime_Call(self): + r = parse("born == datetime(2012, 11, 9)") + self.assertEqual(type(r), dict) + self.assertEqual(r, {"born": datetime(2012, 11, 9)}) + + def test_Attribute(self): + r = parse("Invoice.number == 1") + self.assertEqual(type(r), dict) + self.assertEqual(r, {"Invoice.number": 1}) + + def test_unparsed_statement(self): + self.assertRaises(ParseError, parse, 'print ("hello")') + + def test_bad_Expr(self): + self.assertRaises(ParseError, parse, "a | 2") + + +class TestMongoValidator(TestCase): + def test_unique_fail(self): + """relying on POST and PATCH tests since we don't have an active + app_context running here""" + pass + + def test_unique_success(self): + """relying on POST and PATCH tests since we don't have an active + app_context running here""" + pass + + def test_decimal_fail(self): + schema = {"decimal": {"type": "decimal"}} + doc = {"decimal": "not_a_decimal"} + v = Validator(schema, None) + self.assertFalse(v.validate(doc)) + self.assertTrue("decimal" in v.errors) + self.assertTrue("decimal" in v.errors["decimal"]) + + def test_decimal_success(self): + schema = {"decimal": {"type": "decimal"}} + doc = {"decimal": decimal128.Decimal128("123.123")} + v = Validator(schema, None) + self.assertTrue(v.validate(doc)) + + def test_objectid_fail(self): + schema = {"id": {"type": "objectid"}} + doc = {"id": "not_an_object_id"} + v = Validator(schema, None) + self.assertFalse(v.validate(doc)) + self.assertTrue("id" in v.errors) + self.assertTrue("objectid" in v.errors["id"]) + + def test_objectid_success(self): + schema = {"id": {"type": "objectid"}} + doc = {"id": ObjectId("50656e4538345b39dd0414f0")} + v = Validator(schema, None) + self.assertTrue(v.validate(doc)) + + def test_dbref_fail(self): + schema = {"id": {"type": "dbref"}} + doc = {"id": "not_an_object_id"} + v = Validator(schema, None) + self.assertFalse(v.validate(doc)) + self.assertTrue("id" in v.errors) + self.assertTrue("dbref" in v.errors["id"]) + + def test_dbref_success(self): + schema = {"id": {"type": "dbref"}} + doc = {"id": DBRef("SomeCollection", ObjectId("50656e4538345b39dd0414f0"))} + v = Validator(schema, None) + self.assertTrue(v.validate(doc)) + + def test_reject_invalid_schema(self): + schema = {"a_field": {"foo": "bar"}} + self.assertRaises(SchemaError, lambda: Validator(schema)) + + def test_geojson_not_compilant(self): + schema = {"location": {"type": "point"}} + doc = {"location": [10.0, 123.0]} + v = Validator(schema) + self.assertFalse(v.validate(doc)) + self.assertTrue("location" in v.errors) + self.assertTrue("point" in v.errors["location"]) + + def test_geometry_not_compilant(self): + schema = {"location": {"type": "point"}} + doc = {"location": {"type": "Point", "geometries": [10.0, 123.0]}} + v = Validator(schema) + self.assertFalse(v.validate(doc)) + self.assertTrue("location" in v.errors) + self.assertTrue("point" in v.errors["location"]) + + def test_geometrycollection_not_compilant(self): + schema = {"location": {"type": "geometrycollection"}} + doc = {"location": {"type": "GeometryCollection", "coordinates": [10.0, 123.0]}} + v = Validator(schema) + self.assertFalse(v.validate(doc)) + self.assertTrue("location" in v.errors) + self.assertTrue("geometrycollection" in v.errors["location"]) + + def test_point_success(self): + schema = {"location": {"type": "point"}} + doc = {"location": {"type": "Point", "coordinates": [100.0, 0.0]}} + v = Validator(schema) + self.assertTrue(v.validate(doc)) + + def test_point_fail(self): + schema = {"location": {"type": "point"}} + doc = {"location": {"type": "Point", "coordinates": ["asdasd", 123.0]}} + v = Validator(schema) + self.assertFalse(v.validate(doc)) + self.assertTrue("location" in v.errors) + self.assertTrue("point" in v.errors["location"]) + + def test_point_coordinates_fail(self): + schema = {"location": {"type": "point"}} + doc = {"location": {"type": "Point", "coordinates": [123.0]}} + v = Validator(schema) + self.assertFalse(v.validate(doc)) + self.assertTrue("location" in v.errors) + self.assertTrue("point" in v.errors["location"]) + + def test_point_integer_success(self): + schema = {"location": {"type": "point"}} + doc = {"location": {"type": "Point", "coordinates": [10, 123.0]}} + v = Validator(schema) + self.assertTrue(v.validate(doc)) + + def test_linestring_success(self): + schema = {"location": {"type": "linestring"}} + doc = { + "location": { + "type": "LineString", + "coordinates": [[100.0, 0.0], [101.0, 1.0]], + } + } + v = Validator(schema) + self.assertTrue(v.validate(doc)) + + def test_linestring_fail(self): + schema = {"location": {"type": "linestring"}} + doc = { + "location": { + "type": "LineString", + "coordinates": [[12.0, 123.0], [12, "eve"]], + } + } + v = Validator(schema) + self.assertFalse(v.validate(doc)) + self.assertTrue("location" in v.errors) + self.assertTrue("linestring" in v.errors["location"]) + + def test_polygon_success(self): + schema = {"location": {"type": "polygon"}} + doc = { + "location": { + "type": "Polygon", + "coordinates": [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0], + ] + ], + } + } + v = Validator(schema) + self.assertTrue(v.validate(doc)) + + def test_polygon_fail(self): + schema = {"location": {"type": "polygon"}} + doc = { + "location": { + "type": "Polygon", + "coordinates": [[[12.0, 23.0], [12.3, 12.5]], ["eve"]], + } + } + v = Validator(schema) + self.assertFalse(v.validate(doc)) + self.assertTrue("location" in v.errors) + self.assertTrue("polygon" in v.errors["location"]) + + def test_multipoint_success(self): + schema = {"location": {"type": "multipoint"}} + doc = { + "location": { + "type": "MultiPoint", + "coordinates": [[100.0, 0.0], [101.0, 1.0]], + } + } + v = Validator(schema) + self.assertTrue(v.validate(doc)) + + def test_multilinestring_success(self): + schema = {"location": {"type": "multilinestring"}} + doc = { + "location": { + "type": "MultiLineString", + "coordinates": [ + [[100.0, 0.0], [101.0, 1.0]], + [[102.0, 2.0], [103.0, 3.0]], + ], + } + } + v = Validator(schema) + self.assertTrue(v.validate(doc)) + + def test_multipolygon_success(self): + schema = {"location": {"type": "multipolygon"}} + doc = { + "location": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [102.0, 2.0], + [103.0, 2.0], + [103.0, 3.0], + [102.0, 3.0], + [102.0, 2.0], + ] + ], + [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0], + ], + [ + [100.2, 0.2], + [100.8, 0.2], + [100.8, 0.8], + [100.2, 0.8], + [100.2, 0.2], + ], + ], + ], + } + } + v = Validator(schema) + self.assertTrue(v.validate(doc)) + + def test_geometrycollection_success(self): + schema = {"locations": {"type": "geometrycollection"}} + doc = { + "locations": { + "type": "GeometryCollection", + "geometries": [ + {"type": "Point", "coordinates": [100.0, 0.0]}, + {"type": "LineString", "coordinates": [[101.0, 0.0], [102.0, 1.0]]}, + ], + } + } + v = Validator(schema) + self.assertTrue(v.validate(doc)) + + def test_geometrycollection_fail(self): + schema = {"locations": {"type": "geometrycollection"}} + doc = { + "locations": { + "type": "GeometryCollection", + "geometries": [{"type": "GeoJSON", "badinput": "lolololololol"}], + } + } + v = Validator(schema) + self.assertFalse(v.validate(doc)) + self.assertTrue("locations" in v.errors) + self.assertTrue("geometrycollection" in v.errors["locations"]) + + def test_feature_success(self): + schema = {"locations": {"type": "feature"}} + doc = { + "locations": { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0], + ] + ], + }, + } + } + v = Validator(schema) + self.assertTrue(v.validate(doc)) + + def test_feature_fail(self): + schema = {"locations": {"type": "feature"}} + doc = { + "locations": { + "type": "Feature", + "geometries": [ + { + "type": "Polygon", + "coordinates": [ + [[100.0, 0.0], [101.0, 0.0], [101.0, 1.0], [100.0, 0.0]] + ], + } + ], + } + } + v = Validator(schema) + self.assertFalse(v.validate(doc)) + self.assertTrue("locations" in v.errors) + self.assertTrue("feature" in v.errors["locations"]) + + def test_featurecollection_success(self): + schema = {"locations": {"type": "featurecollection"}} + doc = { + "locations": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [102.0, 0.5]}, + } + ], + } + } + v = Validator(schema) + self.assertTrue(v.validate(doc)) + + def test_featurecollection_fail(self): + schema = {"locations": {"type": "featurecollection"}} + doc = { + "locations": { + "type": "FeatureCollection", + "geometry": {"type": "Point", "coordinates": [100.0, 0.0]}, + } + } + v = Validator(schema) + self.assertFalse(v.validate(doc)) + self.assertTrue("locations" in v.errors) + self.assertTrue("featurecollection" in v.errors["locations"]) + + def test_dependencies_with_defaults(self): + schema = { + "test_field": {"dependencies": "foo"}, + "foo": {"type": "string", "default": "foo"}, + "bar": {"type": "string", "default": "bar"}, + } + doc = {"test_field": "foobar"} + + # With `dependencies` as a str + v = Validator(schema) + self.assertTrue(v.validate(doc)) + + # With `dependencies` as a dict + schema["test_field"] = {"dependencies": {"foo": "foo", "bar": "bar"}} + v = Validator(schema) + self.assertTrue(v.validate(doc)) + + # With `dependencies` as a list + schema["test_field"] = {"dependencies": ["foo", "bar"]} + v = Validator(schema) + self.assertTrue(v.validate(doc)) + + +class TestMongoDriver(TestBase): + def test_combine_queries(self): + mongo = Mongo(None) + query_a = {"username": {"$exists": True}} + query_b = {"username": "mike"} + combined = mongo.combine_queries(query_a, query_b) + self.assertEqual( + combined, {"$and": [{"username": {"$exists": True}}, {"username": "mike"}]} + ) + + def test_json_encoder_class(self): + mongo = Mongo(None) + self.assertTrue((mongo.json_encoder_class(), MongoJSONEncoder)) + self.assertTrue((mongo.json_encoder_class(), json.JSONEncoder)) + + def test_get_value_from_query(self): + mongo = Mongo(None) + simple_query = {"_id": "abcdef012345678901234567"} + compound_query = { + "$and": [ + {"username": {"$exists": False}}, + {"_id": "abcdef012345678901234567"}, + ] + } + self.assertEqual( + mongo.get_value_from_query(simple_query, "_id"), "abcdef012345678901234567" + ) + self.assertEqual( + mongo.get_value_from_query(compound_query, "_id"), + "abcdef012345678901234567", + ) + + def test_query_contains_field(self): + mongo = Mongo(None) + simple_query = {"_id": "abcdef012345678901234567"} + compound_query = { + "$and": [ + {"username": {"$exists": False}}, + {"_id": "abcdef012345678901234567"}, + ] + } + self.assertTrue(mongo.query_contains_field(simple_query, "_id")) + self.assertFalse(mongo.query_contains_field(simple_query, "fake-field")) + self.assertTrue(mongo.query_contains_field(compound_query, "_id")) + self.assertFalse(mongo.query_contains_field(compound_query, "fake-field")) + + def test_delete_returns_status(self): + db = self.connection[MONGO_DBNAME] + count = db.contacts.count_documents({}) + result = db.contacts.delete_many({}) + self.assertEqual(count, result.deleted_count) + self.assertEqual(True, result.acknowledged) + self.connection.close() diff --git a/tests/test_io/multi_mongo.py b/tests/test_io/multi_mongo.py new file mode 100644 index 000000000..ba37a83f8 --- /dev/null +++ b/tests/test_io/multi_mongo.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +from datetime import datetime + +import pytest +import simplejson as json +from bson import ObjectId +from pymongo import MongoClient +from pymongo.errors import OperationFailure + +import eve +from eve.auth import BasicAuth +from tests import TestBase +from tests.test_settings import ( + MONGO1_DBNAME, + MONGO1_PASSWORD, + MONGO1_USERNAME, + MONGO_DBNAME, + MONGO_HOST, + MONGO_PORT, +) + + +class TestMultiMongo(TestBase): + def setUp(self): + super().setUp() + + self.setupDB2() + + schema = {"author": {"type": "string"}, "title": {"type": "string"}} + settings = {"schema": schema, "mongo_prefix": "MONGO1"} + + self.app.register_resource("works", settings) + + def tearDown(self): + super().tearDown() + self.dropDB2() + + def setupDB2(self): + self.connection = MongoClient() + self.connection.drop_database(MONGO1_DBNAME) + db = self.connection[MONGO1_DBNAME] + try: + db.command("dropUser", MONGO1_USERNAME) + except OperationFailure: + pass + db.command( + "createUser", MONGO1_USERNAME, pwd=MONGO1_PASSWORD, roles=["dbAdmin"] + ) + self.bulk_insert2() + + def dropDB2(self): + self.connection = MongoClient() + self.connection.drop_database(MONGO1_DBNAME) + self.connection.close() + + def bulk_insert2(self): + _db = self.connection[MONGO1_DBNAME] + works = self.random_works(self.known_resource_count) + _db.works.insert_many(works) + self.work = _db.works.find_one() + + def random_works(self, num): + works = [] + for i in range(num): + dt = datetime.now() + work = { + "author": self.random_string(20), + "title": self.random_string(30), + eve.LAST_UPDATED: dt, + eve.DATE_CREATED: dt, + } + works.append(work) + return works + + +class TestMethodsAcrossMultiMongo(TestMultiMongo): + def test_get_multidb(self): + # test that a GET on 'works' reads from MONGO1 + id_field = self.domain["works"]["id_field"] + r, s = self.get("works/%s" % self.work[id_field]) + self.assert200(s) + self.assertEqual(r["author"], self.work["author"]) + + # while 'contacts' endpoint reads from MONGO + id_field = self.domain["contacts"]["id_field"] + r, s = self.get(self.known_resource, item=self.item_id) + self.assert200(s) + self.assertEqual(r[id_field], self.item_id) + + def test_post_multidb(self): + # test that a POST on 'works' stores data to MONGO1 + work = self._save_work() + db = self.connection[MONGO1_DBNAME] + id_field = self.domain["works"]["id_field"] + new = db.works.find_one({id_field: ObjectId(work[id_field])}) + self.assertTrue(new is not None) + + # while 'contacts' endpoint stores data to MONGO + contact = {"ref": "1234567890123456789054321"} + r, s = self.post(self.known_resource_url, data=contact) + self.assert201(s) + db = self.connection[MONGO_DBNAME] + id_field = self.domain["contacts"]["id_field"] + new = db.contacts.find_one({id_field: ObjectId(r[id_field])}) + self.assertTrue(new is not None) + + def test_patch_multidb(self): + # test that a PATCH on 'works' udpates data on MONGO1 + work = self._save_work() + id_field = self.domain["works"]["id_field"] + id, etag = work[id_field], work[eve.ETAG] + changes = {"author": "mike"} + + headers = [("Content-Type", "application/json"), ("If-Match", etag)] + r = self.test_client.patch( + "works/%s" % id, data=json.dumps(changes), headers=headers + ) + self.assert200(r.status_code) + + db = self.connection[MONGO1_DBNAME] + updated = db.works.find_one({id_field: ObjectId(id)}) + self.assertEqual(updated["author"], "mike") + + # while 'contacts' endpoint updates data on MONGO + field, value = "ref", "1234567890123456789012345" + changes = {field: value} + headers = [("Content-Type", "application/json"), ("If-Match", self.item_etag)] + id_field = self.domain["contacts"]["id_field"] + r = self.test_client.patch( + self.item_id_url, data=json.dumps(changes), headers=headers + ) + self.assert200(r.status_code) + + db = self.connection[MONGO_DBNAME] + updated = db.contacts.find_one({id_field: ObjectId(self.item_id)}) + self.assertEqual(updated[field], value) + + def test_put_multidb(self): + # test that a PUT on 'works' udpates data on MONGO1 + work = self._save_work() + id_field = self.domain["works"]["id_field"] + id, etag = work[id_field], work[eve.ETAG] + changes = {"author": "mike", "title": "Eve for dummies"} + + headers = [("Content-Type", "application/json"), ("If-Match", etag)] + r = self.test_client.put( + "works/%s" % id, data=json.dumps(changes), headers=headers + ) + self.assert200(r.status_code) + + db = self.connection[MONGO1_DBNAME] + updated = db.works.find_one({id_field: ObjectId(id)}) + self.assertEqual(updated["author"], "mike") + + # while 'contacts' endpoint updates data on MONGO + field, value = "ref", "1234567890123456789012345" + changes = {field: value} + headers = [("Content-Type", "application/json"), ("If-Match", self.item_etag)] + id_field = self.domain["contacts"]["id_field"] + r = self.test_client.put( + self.item_id_url, data=json.dumps(changes), headers=headers + ) + self.assert200(r.status_code) + + db = self.connection[MONGO_DBNAME] + updated = db.contacts.find_one({id_field: ObjectId(self.item_id)}) + self.assertEqual(updated[field], value) + + def test_delete_multidb(self): + # test that DELETE on 'works' deletes data on MONGO1 + work = self._save_work() + id_field = self.domain["works"]["id_field"] + id, etag = work[id_field], work[eve.ETAG] + r = self.test_client.delete("works/%s" % id, headers=[("If-Match", etag)]) + self.assert204(r.status_code) + db = self.connection[MONGO1_DBNAME] + lost = db.works.find_one({id_field: ObjectId(id)}) + self.assertEqual(lost, None) + + # while 'contacts' still deletes on MONGO + r = self.test_client.delete( + self.item_id_url, headers=[("If-Match", self.item_etag)] + ) + self.assert204(r.status_code) + db = self.connection[MONGO_DBNAME] + id_field = self.domain["contacts"]["id_field"] + lost = db.contacts.find_one({id_field: ObjectId(self.item_id)}) + self.assertEqual(lost, None) + + def test_create_index_with_mongo_uri_and_prefix(self): + self.app.config["MONGO_URI"] = "mongodb://%s:%s/%s" % ( + MONGO_HOST, + MONGO_PORT, + MONGO_DBNAME, + ) + self.app.config["MONGO1_URI"] = "mongodb://%s:%s/%s" % ( + MONGO_HOST, + MONGO_PORT, + MONGO1_DBNAME, + ) + settings = { + "schema": { + "name": {"type": "string"}, + "other_field": {"type": "string"}, + "lat_long": {"type": "list"}, + }, + "mongo_indexes": { + "name": [("name", 1)], + "composed": [("name", 1), ("other_field", 1)], + "arguments": ([("lat_long", "2d")], {"sparse": True}), + }, + "mongo_prefix": "MONGO1", + } + self.app.register_resource("mongodb_features", settings) + + # check if index was created using MONGO1 prefix + db = self.connection[MONGO1_DBNAME] + self.assertTrue("mongodb_features" in db.list_collection_names()) + coll = db["mongodb_features"] + indexes = coll.index_information() + + # at least there is an index for the _id field plus the indexes + self.assertTrue(len(indexes) > len(settings["mongo_indexes"])) + + def _save_work(self): + work = {"author": "john doe", "title": "Eve for Dummies"} + r, s = self.post("works", data=work) + self.assert201(s) + return r + + +class MyBasicAuth(BasicAuth): + def check_auth(self, username, password, allowed_roles, resource, method): + self.set_mongo_prefix("MONGO1") + return True + + +class TestMultiMongoAuth(TestMultiMongo): + def test_get_multidb(self): + self.domain["works"]["mongo_prefix"] = "MONGO" + self.domain["works"]["public_item_methods"] = [] + + headers = [("Authorization", "Basic YWRtaW46c2VjcmV0")] + + # this will 404 since there's no 'works' collection on MONGO, + id_field = self.domain["works"]["id_field"] + r = self.test_client.get("works/%s" % self.work[id_field], headers=headers) + self.assert404(r.status_code) + + # now set a custom auth class which sets mongo_prefix at MONGO1 + self.domain["works"]["authentication"] = MyBasicAuth + + # this will 200 just fine as the custom auth class has precedence over + # endpoint configuration. + r = self.test_client.get("works/%s" % self.work[id_field], headers=headers) + self.assert200(r.status_code) + # test that we are indeed reading from the correct database instance. + payl = json.loads(r.get_data().decode("utf-8")) + self.assertEqual(payl["author"], self.work["author"]) + + # 'contacts' still reads from MONGO + r = self.test_client.get( + "%s/%s" % (self.known_resource_url, self.item_id), headers=headers + ) + self.assert200(r.status_code) diff --git a/eve/tests/logging.py b/tests/test_logging.py similarity index 52% rename from eve/tests/logging.py rename to tests/test_logging.py index 3a8d15505..8423cae76 100644 --- a/eve/tests/logging.py +++ b/tests/test_logging.py @@ -1,21 +1,21 @@ -from eve.tests import TestBase from testfixtures import log_capture +from . import TestBase + class TestUtils(TestBase): - """ collection, document and home_link methods (and resource_uri, which is + """collection, document and home_link methods (and resource_uri, which is used by all of them) are tested in 'tests.methods' since we need an active flaskapp context """ @log_capture() - def test_logging_info(self, l): - self.app.logger.info('test info') - l.check( - ('eve', 'INFO', 'test info') - ) + def test_logging_info(self, log): + self.app.logger.propagate = True + self.app.logger.info("test info") + log.check(("eve", "INFO", "test info")) - log_record = l.records[0] + log_record = log.records[0] self.assertEqual(log_record.clientip, None) self.assertEqual(log_record.method, None) self.assertEqual(log_record.url, None) diff --git a/tests/test_prefix.py b/tests/test_prefix.py new file mode 100644 index 000000000..750e3c4cb --- /dev/null +++ b/tests/test_prefix.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +RESOURCE_METHODS = ["GET", "POST"] +URL_PREFIX = "prefix" +DOMAIN = {"contacts": {}} diff --git a/tests/test_prefix_version.py b/tests/test_prefix_version.py new file mode 100644 index 000000000..12142bd6b --- /dev/null +++ b/tests/test_prefix_version.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +URL_PREFIX = "prefix" +API_VERSION = "v1" +DOMAIN = {"contacts": {}} diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 000000000..8adee363f --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,442 @@ +# -*- coding: utf-8 -*- +import copy + +MONGO_HOST = "localhost" +MONGO_PORT = 27017 +MONGO_USERNAME = MONGO1_USERNAME = "test_user" +MONGO_PASSWORD = MONGO1_PASSWORD = "test_pw" +MONGO_DBNAME, MONGO1_DBNAME = "eve_test", "eve_test1" +ID_FIELD = "_id" + +RESOURCE_METHODS = ["GET", "POST", "DELETE"] +ITEM_METHODS = ["GET", "PATCH", "DELETE", "PUT"] +ITEM_CACHE_CONTROL = "" +ITEM_LOOKUP = True +ITEM_LOOKUP_FIELD = ID_FIELD + + +disabled_bulk = { + "url": "somebulkurl", + "item_title": "bulkdisabled", + "bulk_enabled": False, + "schema": {"string_field": {"type": "string"}}, +} + + +contacts = { + "url": "arbitraryurl", + "cache_control": "max-age=20,must-revalidate", + "cache_expires": 20, + "item_title": "contact", + "additional_lookup": { + "url": r'regex("[\w]+")', # to be unique field + "field": "ref", + }, + "datasource": {"filter": {"username": {"$exists": False}}}, + "schema": { + "ref": { + "type": "string", + "minlength": 25, + "maxlength": 25, + "required": True, + "unique": True, + }, + "media": {"type": "media"}, + "prog": {"type": "integer"}, + "role": {"type": "list", "allowed": ["agent", "client", "vendor"]}, + "rows": { + "type": "list", + "schema": { + "type": "dict", + "schema": { + "sku": {"type": "string", "maxlength": 24}, + "price": {"type": "integer"}, + }, + }, + }, + "alist": {"type": "list", "items": [{"type": "string"}, {"type": "integer"}]}, + "location": { + "type": "dict", + "schema": { + "address": {"type": "string"}, + "city": {"type": "string", "required": True}, + }, + }, + "born": {"type": "datetime"}, + "tid": {"type": "objectid", "nullable": True}, + "title": {"type": "string", "default": "Mr."}, + "id_list": {"type": "list", "schema": {"type": "objectid"}}, + "id_list_of_dict": { + "type": "list", + "schema": {"type": "dict", "schema": {"id": {"type": "objectid"}}}, + }, + "id_list_fixed_len": {"type": "list", "items": [{"type": "objectid"}]}, + "dict_list_fixed_len": { + "type": "list", + "items": [ + {"type": "dict", "schema": {"key1": {"type": "string"}}}, + {"type": "dict", "schema": {"key2": {"type": "integer"}}}, + ], + }, + "dependency_field1": {"type": "string", "default": "default"}, + "dependency_field2": {"type": "string", "dependencies": ["dependency_field1"]}, + "dependency_field3": { + "type": "string", + "dependencies": {"dependency_field1": "value"}, + }, + "dependency_field4": {"type": "string"}, + "dependency_field5": {"type": "string", "dependencies": ["dependency_field4"]}, + "dependency_field6": { + "type": "string", + "dependencies": {"dependency_field4": "value"}, + }, + "read_only_field": {"type": "string", "default": "default", "readonly": True}, + "dict_with_read_only": { + "type": "dict", + "schema": { + "read_only_in_dict": { + "type": "string", + "default": "default", + "readonly": True, + } + }, + }, + "dict_with_nested_default": { + "type": "dict", + "schema": { + "nested_field": {"type": "string"}, + "nested_field_with_default": {"type": "string", "default": "nested"}, + }, + }, + "key1": {"type": "string"}, + "keyschema_dict": { + "type": "dict", + "keyschema": {"type": "string", "regex": "[a-z]+"}, + }, + "valueschema_dict": {"type": "dict", "valueschema": {"type": "integer"}}, + "aninteger": {"type": "integer"}, + "afloat": {"type": "float"}, + "anumber": {"type": "number"}, + "dict_valueschema": { + "type": "dict", + "valueschema": { + "type": "dict", + "schema": {"challenge": {"type": "objectid"}}, + }, + }, + "unsetted_default_value_field": {"type": "string", "default": "value"}, + }, +} + +users = copy.deepcopy(contacts) +users["url"] = "users" +users["datasource"] = { + "source": "contacts", + "filter": {"username": {"$exists": True}}, + "projection": {"username": 1, "ref": 1}, +} +users["schema"]["username"] = {"type": "string", "required": True} +users["resource_methods"] = ["DELETE", "POST", "GET"] +users["item_title"] = "user" +users["additional_lookup"]["field"] = "username" + +contacts_hide_born = copy.deepcopy(contacts) +contacts_hide_born["url"] = "contacts/hide_born" +contacts_hide_born["datasource"]["source"] = "contacts" +contacts_hide_born["datasource"]["projection"] = {"born": 0} + +contacts_hide_media = copy.deepcopy(contacts) +contacts_hide_media["url"] = "contacts/hide_media" +contacts_hide_media["datasource"]["source"] = "contacts" +contacts_hide_media["datasource"]["projection"] = {"media": 0, "born": 0} + +invoices = { + "schema": { + "inv_number": {"type": "string"}, + "person": {"type": "objectid", "data_relation": {"resource": "contacts"}}, + "invoicing_contacts": { + "type": "list", + "data_relation": {"resource": "contacts"}, + }, + "persondbref": {"type": "dbref", "data_relation": {"resource": "contacts"}}, + "decimal_number": {"type": "decimal"}, + } +} + +# This resource is used to test app initialization when using resource +# level versioning +versioned_invoices = copy.deepcopy(invoices) +versioned_invoices["versioning"] = True + +# This resource is used to test subresources that have a reference/objectid +# field that is set to be required. +required_invoices = copy.deepcopy(invoices) +required_invoices["schema"]["person"]["required"] = True + +companies = { + "item_title": "company", + "schema": { + "departments": { + "type": "list", + "schema": { + "type": "dict", + "schema": { + "title": {"type": "string"}, + "members": { + "type": "list", + "schema": { + "type": "objectid", + "data_relation": {"resource": "contacts"}, + }, + }, + }, + }, + }, + "holding": {"type": "objectid", "data_relation": {"resource": "companies"}}, + }, +} + +users_overseas = copy.deepcopy(users) +users_overseas["url"] = "users/overseas" +users_overseas["datasource"] = {"source": "contacts"} + +payments = {"resource_methods": ["GET"], "item_methods": ["GET"]} + +empty = copy.deepcopy(invoices) + +user_restricted_access = copy.deepcopy(contacts) +user_restricted_access["url"] = "restricted" +user_restricted_access["datasource"] = {"source": "contacts"} + +users_invoices = copy.deepcopy(invoices) +users_invoices["url"] = 'users//invoices' +users_invoices["datasource"] = {"source": "invoices"} + +users_required_invoices = copy.deepcopy(required_invoices) +users_required_invoices[ + "url" +] = 'users//required_invoices' +users_required_invoices["datasource"] = {"source": "required_invoices"} + +users_searches = copy.deepcopy(invoices) +users_searches["datasource"] = {"source": "invoices"} +users_searches["url"] = 'users//saved_searches' + +internal_transactions = { + "resource_methods": ["GET"], + "item_methods": ["GET"], + "internal_resource": True, +} + +ids = { + "query_objectid_as_string": True, + "item_lookup_field": "id", + "resource_methods": ["POST", "GET"], + "schema": {"id": {"type": "string"}, "name": {"type": "string"}}, +} + +login = { + "item_title": "login", + "url": "login", + "datasource": {"projection": {"password": 0}}, + "schema": { + "email": {"type": "string", "required": True, "unique": True}, + "password": {"type": "string", "required": True}, + }, +} + +# This resource is used to test resource-specific id fields. +products = { + "id_field": "sku", + "item_lookup_field": "sku", + "item_url": 'regex("[A-Z]+")', + "schema": { + "sku": {"type": "string", "maxlength": 16}, + "title": {"type": "string", "minlength": 4, "maxlength": 32}, + "parent_product": {"type": "string", "data_relation": {"resource": "products"}}, + }, +} + +test_patch = { + "datasource": {"source": "test_patch"}, + "normalize_on_patch": False, + "schema": { + "name": {"type": "string", "required": True}, + "contact": { + "type": "dict", + "required": True, + "schema": { + "phone": { + "type": "string", + "required": False, + "default": "default_phone", + }, + "email": { + "type": "string", + "required": False, + "default": "default_email", + }, + }, + }, + }, +} + +tenant_a = { + "datasource": {"source": "tenants", "filter": {"_tenant": "tenant_a"}}, + "schema": { + "_tenant": {"type": "string", "readonly": True, "default": "tenant_a"}, + "name": {"type": "string", "required": True, "unique_within_resource": True}, + }, +} + +tenant_b = { + "datasource": {"source": "tenants", "filter": {"_tenant": "tenant_b"}}, + "schema": { + "_tenant": {"type": "string", "readonly": True, "default": "tenant_b"}, + "name": {"type": "string", "required": True, "unique_within_resource": True}, + }, +} + +test_unique = { + "datasource": {"source": "test_unique"}, + "schema": { + "unique_attribute": {"type": "string", "unique": True}, + "unique_in_dict_attribute": { + "type": "dict", + "schema": {"unique_attribute": {"type": "string", "unique": True}}, + }, + "unique_in_list_attribute": { + "type": "list", + "schema": { + "type": "dict", + "schema": {"unique_attribute": {"type": "string", "unique": True}}, + }, + }, + "unique_in_deep_dict_attribute": { + "type": "dict", + "schema": { + "dict_attribute": { + "type": "dict", + "schema": {"unique_attribute": {"type": "string", "unique": True}}, + } + }, + }, + "unique_in_deep_list_attribute": { + "type": "dict", + "schema": { + "list_attribute": { + "type": "list", + "schema": { + "type": "dict", + "schema": { + "unique_attribute": {"type": "string", "unique": True} + }, + }, + } + }, + }, + "unique_within_resource_attribute": { + "type": "string", + "unique_within_resource": True, + }, + }, +} + +test_unique_nested = {"datasource": {"source": "test_unique_nested"}, "schema": {}} + +credit_rules = { + "allow_unknown": True, + "schema": { + "name": {"type": "string"}, + "amount": {"type": "float", "default": 0.00, "min": 0.00, "required": True}, + "start": {"type": "string", "required": True}, + "duration": { + "type": "string", + "allowed": ["days", "weeks", "months", "years", "one-time"], + "required": True, + }, + "prepaid": {"type": "boolean", "default": False}, + "expiration_duration": { + "type": "string", + "allowed": ["days", "weeks", "months", "years"], + "required": False, + }, + }, +} + +brands = { + "item_title": "brand", + "schema": {"name": {"type": "string"}, "address": "string"}, +} + +components = { + "item_title": "component", + "schema": { + "name": {"type": "string"}, + "price": "integer", + "brand": {"type": "objectid", "data_relation": {"resource": "brands"}}, + }, +} + +computers = { + "item_title": "computers", + "schema": { + "name": {"type": "string"}, + "components": { + "type": "dict", + "schema": { + "cpu": { + "type": "objectid", + "data_relation": {"resource": "components"}, + }, + "motherboard": { + "type": "objectid", + "data_relation": {"resource": "components"}, + }, + }, + }, + }, +} + +child_products = copy.deepcopy(products) +child_products["url"] = 'products//children' +child_products["datasource"] = {"source": "products"} + +exclusion = copy.deepcopy(contacts) +exclusion["url"] = "exclusion" +exclusion["soft_delete"] = True +exclusion["datasource"]["source"] = "contacts" +exclusion["datasource"]["projection"] = {"int": 0} + +DOMAIN = { + "disabled_bulk": disabled_bulk, + "contacts": contacts, + "users": users, + "users_overseas": users_overseas, + "contacts_hide_born": contacts_hide_born, + "contacts_hide_media": contacts_hide_media, + "invoices": invoices, + "versioned_invoices": versioned_invoices, + "required_invoices": required_invoices, + "payments": payments, + "empty": empty, + "restricted": user_restricted_access, + "peopleinvoices": users_invoices, + "peoplerequiredinvoices": users_required_invoices, + "peoplesearches": users_searches, + "companies": companies, + "internal_transactions": internal_transactions, + "ids": ids, + "login": login, + "products": products, + "child_products": child_products, + "exclusion": exclusion, + "test_patch": test_patch, + "tenant_a": tenant_a, + "tenant_b": tenant_b, + "test_unique": test_unique, + "credit_rules": credit_rules, + "brands": brands, + "components": components, + "computers": computers, +} diff --git a/tests/test_settings_env.py b/tests/test_settings_env.py new file mode 100644 index 000000000..3a3b1e107 --- /dev/null +++ b/tests/test_settings_env.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- + +# this is just a helper file which we are going +# to try to load with environmental variable in +# test_existing_env_config() test case + +DOMAIN = {"env_domain": {}} diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 000000000..9397b398a --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +API_VERSION = "v1" +DOMAIN = {"contacts": {}} diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 000000000..4c4a66d22 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,349 @@ +# -*- coding: utf-8 -*- + +import copy +import hashlib +from datetime import datetime, timedelta + +from bson.json_util import dumps + +from eve.utils import ( + config, + date_to_str, + debug_error_message, + document_etag, + extract_key_values, + import_from_string, + parse_request, + querydef, + str_to_date, + validate_filters, + weak_date, +) + +from . import TestBase + + +class TestUtils(TestBase): + """collection, document and home_link methods (and resource_uri, which is + used by all of them) are tested in 'tests.methods' since we need an active + flaskapp context + """ + + def setUp(self): + super().setUp() + self.dt_fmt = config.DATE_FORMAT + self.datestr = "Tue, 18 Sep 2012 10:12:30 GMT" + self.valid = datetime.strptime(self.datestr, self.dt_fmt) + self.etag = "56eaadbbd9fa287e7270cf13a41083c94f52ab9b" + + def test_parse_request_where(self): + self.app.config["DOMAIN"][self.known_resource]["allowed_filters"] = ["ref"] + with self.app.test_request_context(): + self.assertEqual(parse_request(self.known_resource).where, None) + with self.app.test_request_context("/?where=hello"): + self.assertEqual(parse_request(self.known_resource).where, "hello") + + def test_parse_request_sort(self): + with self.app.test_request_context(): + self.assertEqual(parse_request(self.known_resource).sort, None) + with self.app.test_request_context("/?sort=hello"): + self.assertEqual(parse_request(self.known_resource).sort, "hello") + + def test_parse_request_page(self): + with self.app.test_request_context(): + self.assertEqual(parse_request(self.known_resource).page, 1) + with self.app.test_request_context("/?page=2"): + self.assertEqual(parse_request(self.known_resource).page, 2) + with self.app.test_request_context("/?page=-1"): + self.assertEqual(parse_request(self.known_resource).page, 1) + with self.app.test_request_context("/?page=0"): + self.assertEqual(parse_request(self.known_resource).page, 1) + with self.app.test_request_context("/?page=1.1"): + self.assertEqual(parse_request(self.known_resource).page, 1) + with self.app.test_request_context("/?page=string"): + self.assertEqual(parse_request(self.known_resource).page, 1) + + def test_parse_request_max_results(self): + default = config.PAGINATION_DEFAULT + limit = config.PAGINATION_LIMIT + with self.app.test_request_context(): + self.assertEqual(parse_request(self.known_resource).max_results, default) + with self.app.test_request_context("/?max_results=%d" % (limit + 1)): + self.assertEqual(parse_request(self.known_resource).max_results, limit) + with self.app.test_request_context("/?max_results=2"): + self.assertEqual(parse_request(self.known_resource).max_results, 2) + with self.app.test_request_context("/?max_results=-1"): + self.assertEqual(parse_request(self.known_resource).max_results, default) + with self.app.test_request_context("/?max_results=0"): + self.assertEqual(parse_request(self.known_resource).max_results, default) + with self.app.test_request_context("/?max_results=1.1"): + self.assertEqual(parse_request(self.known_resource).max_results, 1) + with self.app.test_request_context("/?max_results=string"): + self.assertEqual(parse_request(self.known_resource).max_results, default) + + def test_parse_request_max_results_disabled_pagination(self): + self.app.config["DOMAIN"][self.known_resource]["pagination"] = False + default = 0 + limit = config.PAGINATION_LIMIT + with self.app.test_request_context(): + self.assertEqual(parse_request(self.known_resource).max_results, default) + with self.app.test_request_context("/?max_results=%d" % (limit + 1)): + self.assertEqual(parse_request(self.known_resource).max_results, limit + 1) + with self.app.test_request_context("/?max_results=2"): + self.assertEqual(parse_request(self.known_resource).max_results, 2) + with self.app.test_request_context("/?max_results=-1"): + self.assertEqual(parse_request(self.known_resource).max_results, default) + with self.app.test_request_context("/?max_results=0"): + self.assertEqual(parse_request(self.known_resource).max_results, default) + with self.app.test_request_context("/?max_results=1.1"): + self.assertEqual(parse_request(self.known_resource).max_results, 1) + with self.app.test_request_context("/?max_results=string"): + self.assertEqual(parse_request(self.known_resource).max_results, default) + + def test_parse_request_if_modified_since(self): + ims = "If-Modified-Since" + with self.app.test_request_context(): + self.assertEqual(parse_request(self.known_resource).if_modified_since, None) + with self.app.test_request_context(headers=None): + self.assertEqual(parse_request(self.known_resource).if_modified_since, None) + with self.app.test_request_context(headers={ims: self.datestr}): + self.assertEqual( + parse_request(self.known_resource).if_modified_since, + self.valid + timedelta(seconds=1), + ) + with self.app.test_request_context(headers={ims: "not-a-date"}): + self.assertRaises(ValueError, parse_request, self.known_resource) + with self.app.test_request_context( + headers={ims: self.datestr.replace("GMT", "UTC")} + ): + self.assertRaises(ValueError, parse_request, self.known_resource) + self.assertRaises(ValueError, parse_request, self.known_resource) + + def test_parse_request_if_none_match(self): + with self.app.test_request_context(): + self.assertEqual(parse_request(self.known_resource).if_none_match, None) + with self.app.test_request_context(headers=None): + self.assertEqual(parse_request(self.known_resource).if_none_match, None) + with self.app.test_request_context(headers={"If-None-Match": self.etag}): + self.assertEqual( + parse_request(self.known_resource).if_none_match, self.etag + ) + + def test_parse_request_if_match(self): + with self.app.test_request_context(): + self.assertEqual(parse_request(self.known_resource).if_match, None) + with self.app.test_request_context(headers=None): + self.assertEqual(parse_request(self.known_resource).if_match, None) + with self.app.test_request_context(headers={"If-Match": self.etag}): + self.assertEqual(parse_request(self.known_resource).if_match, self.etag) + + def test_weak_date(self): + with self.app.test_request_context(): + self.app.config["DATE_FORMAT"] = "%Y-%m-%d" + self.assertEqual(weak_date(self.datestr), self.valid + timedelta(seconds=1)) + + def test_str_to_date(self): + self.assertEqual(str_to_date(self.datestr), self.valid) + self.assertRaises(ValueError, str_to_date, "not-a-date") + self.assertRaises(ValueError, str_to_date, self.datestr.replace("GMT", "UTC")) + + def test_date_to_str(self): + self.assertEqual(date_to_str(self.valid), self.datestr) + + def test_querydef(self): + self.assertEqual(querydef(max_results=10), "?max_results=10") + self.assertEqual(querydef(page=10), "?page=10") + self.assertEqual(querydef(where="wherepart"), "?where=wherepart") + self.assertEqual(querydef(sort="sortpart"), "?sort=sortpart") + + self.assertEqual( + querydef(where="wherepart", sort="sortpart"), + "?where=wherepart&sort=sortpart", + ) + self.assertEqual( + querydef(max_results=10, sort="sortpart"), "?max_results=10&sort=sortpart" + ) + + def test_document_etag(self): + test = {"key1": "value1", "another": "value2"} + challenge = dumps(test, sort_keys=True).encode("utf-8") + with self.app.test_request_context(): + self.assertEqual(hashlib.sha1(challenge).hexdigest(), document_etag(test)) + + def test_document_etag_ignore_fields(self): + test = {"key1": "value1", "key2": "value2"} + test_copy = copy.deepcopy(test) + ignore_fields = ["key2"] + test_without_ignore = {"key1": "value1"} + challenge = dumps(test_without_ignore, sort_keys=True).encode("utf-8") + with self.app.test_request_context(): + self.assertEqual( + hashlib.sha1(challenge).hexdigest(), document_etag(test, ignore_fields) + ) + self.assertEqual(test, test_copy) + + # not required fields can not be present + test = {"key1": "value1", "key2": "value2"} + ignore_fields = ["key3"] + test_without_ignore = {"key1": "value1", "key2": "value2"} + challenge = dumps(test_without_ignore, sort_keys=True).encode("utf-8") + with self.app.test_request_context(): + self.assertEqual( + hashlib.sha1(challenge).hexdigest(), document_etag(test, ignore_fields) + ) + + # ignore fiels nested using doting notation + test = {"key1": "value1", "dict": {"key2": "value2", "key3": "value3"}} + test_copy = copy.deepcopy(test) + ignore_fields = ["dict.key2"] + test_without_ignore = {"key1": "value1", "dict": {"key3": "value3"}} + challenge = dumps(test_without_ignore, sort_keys=True).encode("utf-8") + with self.app.test_request_context(): + self.assertEqual( + hashlib.sha1(challenge).hexdigest(), document_etag(test, ignore_fields) + ) + self.assertEqual(test, test_copy) + + # ignore fiels nested using doting notation when a root part of the field is not present + test = {"key1": "value1", "dict": {"key2": "value2"}} + ignore_fields = ["dict2.key3"] + test_without_ignore = {"key1": "value1", "dict": {"key2": "value2"}} + challenge = dumps(test_without_ignore, sort_keys=True).encode("utf-8") + with self.app.test_request_context(): + self.assertEqual( + hashlib.sha1(challenge).hexdigest(), document_etag(test, ignore_fields) + ) + + def test_extract_key_values(self): + test = { + "key1": "value1", + "key2": {"key1": "value2", "nested": {"key1": "value3"}}, + } + self.assertEqual( + list(extract_key_values("key1", test)), ["value1", "value2", "value3"] + ) + + def test_debug_error_message(self): + with self.app.test_request_context(): + self.app.config["DEBUG"] = False + self.assertEqual(debug_error_message("An error message"), None) + self.app.config["DEBUG"] = True + self.assertEqual( + debug_error_message("An error message"), "An error message" + ) + + def test_validate_filters_when_custom_types_are_used(self): + # Filters validation should operate on the active validator instance, + # not on Cerberus' standard one. See #1154. + self.app.config["VALIDATE_FILTERS"] = True + response, status = self.get(self.known_resource, query='?where={"tid":"1234"}') + self.assert400(status) + self.assertTrue("filter on 'tid' is invalid" in response["_error"]["message"]) + + response, status = self.get( + self.known_resource, query='?where={"tid":"5a1154523a6bcc1d245e143d"}' + ) + self.assert200(status) + + def test_validate_filters(self): + self.app.config["DOMAIN"][self.known_resource]["allowed_filters"] = [] + with self.app.test_request_context(): + self.assertTrue( + "key" in validate_filters({"key": "val"}, self.known_resource) + ) + self.assertTrue( + "key" + in validate_filters({"key": ["val1", "val2"]}, self.known_resource) + ) + self.assertTrue( + "key" + in validate_filters( + {"key": {"$in": ["val1", "val2"]}}, self.known_resource + ) + ) + self.assertTrue( + "key" + in validate_filters( + {"$or": [{"key": "val1"}, {"key": "val2"}]}, self.known_resource + ) + ) + self.assertTrue( + "$or" in validate_filters({"$or": "val"}, self.known_resource) + ) + self.assertTrue( + "$or" in validate_filters({"$or": {"key": "val1"}}, self.known_resource) + ) + self.assertTrue( + "$or" in validate_filters({"$or": ["val"]}, self.known_resource) + ) + + self.app.config["DOMAIN"][self.known_resource]["allowed_filters"] = ["key"] + with self.app.test_request_context(): + self.assertTrue( + validate_filters({"key": "val"}, self.known_resource) is None + ) + self.assertTrue( + validate_filters({"key": ["val1", "val2"]}, self.known_resource) is None + ) + self.assertTrue( + validate_filters( + {"key": {"$in": ["val1", "val2"]}}, self.known_resource + ) + is None + ) + self.assertTrue( + validate_filters( + {"$or": [{"key": "val1"}, {"key": "val2"}]}, self.known_resource + ) + is None + ) + + def test_import_from_string(self): + dt = import_from_string("datetime.datetime") + self.assertEqual(dt, datetime) + + +class DummyEvent: + """ + Even handler that records the call parameters and asserts a check + + Usage:: + + app = Eve() + app.on_my_event = DummyEvent(element_not_deleted) + + In the test:: + + assert app.on_my_event.called[0] == expected_param_0 + """ + + def __init__(self, check, deepcopy=False): + """ + :param check: method checking the state of something during the event. + :type: check: callable returning bool + :param deepcopy: Do we need to store a copy of the argument calls? In + some events arguments are changed after the event, so keeping a + reference to the original object doesn't allow a test to check what + was passed. The default is False. + :type deepcopy: bool + """ + self.__called = None + self.__check = check + self.__deepcopy = deepcopy + + def __call__(self, *args): + assert self.__check() + # In some method the arguments are changed after the events + if self.__deepcopy: + args = copy.deepcopy(args) + self.__called = args + + @property + def called(self): + """ + The results of the call to the event. + + :rtype: It returns None if the event hasn't been called or a tuple with + the positional arguments of the last call if called. + """ + return self.__called diff --git a/eve/tests/versioning.py b/tests/versioning.py similarity index 59% rename from eve/tests/versioning.py rename to tests/versioning.py index 9e781a551..a5e1c028c 100644 --- a/eve/tests/versioning.py +++ b/tests/versioning.py @@ -1,83 +1,85 @@ # -*- coding: utf-8 -*- -from bson import ObjectId import copy import time -from eve.tests import TestBase -from eve.tests.utils import DummyEvent -from eve import STATUS, STATUS_OK, ETAG -from eve.tests.test_settings import MONGO_DBNAME + +from bson import ObjectId + +from eve import ETAG, STATUS, STATUS_OK + +from . import TestBase +from .test_settings import MONGO_DBNAME +from .utils import DummyEvent class TestVersioningBase(TestBase): def setUp(self): - self.versioned_field = 'ref' - self.unversioned_field = 'prog' + self.versioned_field = "ref" + self.unversioned_field = "prog" self.fields = [self.versioned_field, self.unversioned_field] - super(TestVersioningBase, self).setUp() + super().setUp() - self.id_field = self.domain[self.known_resource]['id_field'] - self.version_field = self.app.config['VERSION'] - self.latest_version_field = self.app.config['LATEST_VERSION'] - self.document_id_field = (self.id_field + - self.app.config['VERSION_ID_SUFFIX']) - self.known_resource_shadow = self.known_resource + \ - self.app.config['VERSIONS'] + self.id_field = self.domain[self.known_resource]["id_field"] + self.version_field = self.app.config["VERSION"] + self.latest_version_field = self.app.config["LATEST_VERSION"] + self.document_id_field = self.id_field + self.app.config["VERSION_ID_SUFFIX"] + self.known_resource_shadow = self.known_resource + self.app.config["VERSIONS"] self._db = self.connection[MONGO_DBNAME] def tearDown(self): - super(TestVersioningBase, self).tearDown() + super().tearDown() self.connection.close() def enableVersioning(self, partial=False): - del(self.domain['contacts']['schema']['title']['default']) - del(self.domain['contacts']['schema']['dependency_field1']['default']) - del(self.domain['contacts']['schema']['read_only_field']['default']) - del(self.domain['contacts']['schema']['dict_with_read_only'] - ['schema']['read_only_in_dict']['default']) + self.clearSchemaCache() + del self.domain["contacts"]["schema"]["title"]["default"] + del self.domain["contacts"]["schema"]["dependency_field1"]["default"] + del self.domain["contacts"]["schema"]["unsetted_default_value_field"]["default"] + del self.domain["contacts"]["schema"]["read_only_field"]["default"] + del self.domain["contacts"]["schema"]["dict_with_read_only"]["schema"][ + "read_only_in_dict" + ]["default"] if partial is True: - contact_schema = self.domain['contacts']['schema'] - contact_schema[self.unversioned_field]['versioned'] = False + contact_schema = self.domain["contacts"]["schema"] + contact_schema[self.unversioned_field]["versioned"] = False domain = copy.copy(self.domain) for resource, settings in domain.items(): - settings['versioning'] = True - settings['datasource'].pop('projection', None) + settings["versioning"] = True + settings["datasource"].pop("projection", None) self.app.register_resource(resource, settings) - def enableDataVersionRelation(self, embeddable=True, custom_field=None, - custom_field_type='string'): + def enableDataVersionRelation( + self, embeddable=True, custom_field=None, custom_field_type="string" + ): + self.clearSchemaCache() field = { - 'type': 'dict', - 'schema': { - self.app.config['VERSION']: {'type': 'integer'} - }, - 'data_relation': { - 'version': True, - 'resource': 'contacts' - } + "type": "dict", + "schema": {self.app.config["VERSION"]: {"type": "integer"}}, + "data_relation": {"version": True, "resource": "contacts"}, } if custom_field is None: - field['schema'][self.id_field] = {'type': 'objectid'} + field["schema"][self.id_field] = {"type": "objectid"} else: - field['schema'][custom_field] = {'type': custom_field_type} - field['data_relation']['field'] = custom_field + field["schema"][custom_field] = {"type": custom_field_type} + field["data_relation"]["field"] = custom_field if embeddable is True: - field['data_relation']['embeddable'] = True + field["data_relation"]["embeddable"] = True - self.domain['invoices']['schema']['person'] = field + self.domain["invoices"]["schema"]["person"] = field def enableSoftDelete(self): - self.app.config['SOFT_DELETE'] = True + self.clearSchemaCache() + self.app.config["SOFT_DELETE"] = True domain = copy.copy(self.domain) for resource, settings in domain.items(): # rebuild resource settings for soft delete - del settings['soft_delete'] + del settings["soft_delete"] self.app.register_resource(resource, settings) - self.deleted_field = self.app.config['DELETED'] + self.deleted_field = self.app.config["DELETED"] def assertEqualFields(self, obj1, obj2, fields): for field in fields: @@ -91,8 +93,7 @@ def assertLatestVersion(self, response, latest_version): self.assertTrue(self.latest_version_field in response) self.assertEqual(response[self.latest_version_field], latest_version) - def assertDocumentVersionFields( - self, response, version, latest_version=None): + def assertDocumentVersionFields(self, response, version, latest_version=None): self.assertVersion(response, version) if latest_version is None: latest_version = version @@ -103,8 +104,7 @@ def directGetDocument(self, _id): def directGetShadowDocument(self, _id, version): return self._db[self.known_resource_shadow].find_one( - {self.document_id_field: ObjectId(_id), - self.app.config['VERSION']: version} + {self.document_id_field: ObjectId(_id), self.app.config["VERSION"]: version} ) def countDocuments(self, _id=None): @@ -112,16 +112,14 @@ def countDocuments(self, _id=None): if _id is not None: query[self.id_field] = ObjectId(_id) - documents = self._db[self.known_resource].find(query) - return documents.count() + return self._db[self.known_resource].count_documents(query) def countShadowDocuments(self, _id=None): query = {} if _id is not None: query[self.document_id_field] = ObjectId(_id) - documents = self._db[self.known_resource_shadow].find(query) - return documents.count() + return self._db[self.known_resource_shadow].count_documents(query) def assertGoodPutPatch(self, response, status): self.assert200(status) @@ -131,16 +129,16 @@ def assertGoodPutPatch(self, response, status): class TestNormalVersioning(TestVersioningBase): def setUp(self): - super(TestNormalVersioning, self).setUp() + super().setUp() # create some dummy contacts to use for versioning tests self.item = { - self.versioned_field: 'ref value 1..............', - self.unversioned_field: 123 + self.versioned_field: "ref value 1..............", + self.unversioned_field: 123, } self.item_change = { - self.versioned_field: 'ref value 2..............', - self.unversioned_field: 456 + self.versioned_field: "ref value 2..............", + self.unversioned_field: 456, } def insertTestData(self): @@ -148,9 +146,10 @@ def insertTestData(self): self.assert201(status) self.item_id = contact[self.id_field] self.item_etag = contact[ETAG] - self.item_id_url = ('/%s/%s' % - (self.domain[self.known_resource]['url'], - self.item_id)) + self.item_id_url = "/%s/%s" % ( + self.domain[self.known_resource]["url"], + self.item_id, + ) def assertPrimaryAndShadowDocuments(self, _id, version, partial=False): # verify primary document fields @@ -165,25 +164,26 @@ def assertPrimaryAndShadowDocuments(self, _id, version, partial=False): self.assertTrue(shadow_document is not None) self.assertTrue(self.versioned_field in shadow_document) self.assertEqual( - document[self.versioned_field], - shadow_document[self.versioned_field]) + document[self.versioned_field], shadow_document[self.versioned_field] + ) if partial is True: self.assertFalse(self.unversioned_field in shadow_document) else: self.assertTrue(self.unversioned_field in shadow_document) self.assertEqual( document[self.unversioned_field], - shadow_document[self.unversioned_field]) + shadow_document[self.unversioned_field], + ) # verify meta fields self.assertTrue(shadow_document[self.version_field] == version) self.assertTrue(self.document_id_field in shadow_document) self.assertEqual( - document[self.id_field], - shadow_document[self.document_id_field]) + document[self.id_field], shadow_document[self.document_id_field] + ) self.assertTrue(self.id_field in shadow_document) - self.assertTrue(self.app.config['LAST_UPDATED'] in shadow_document) - self.assertTrue(self.app.config['ETAG'] in shadow_document) + self.assertTrue(self.app.config["LAST_UPDATED"] in shadow_document) + self.assertTrue(self.app.config["ETAG"] in shadow_document) # verify that no unexpected fields exist num_meta_fields = 5 # see previous block @@ -193,26 +193,24 @@ def assertPrimaryAndShadowDocuments(self, _id, version, partial=False): self.assertEqual(len(shadow_document.keys()), num_meta_fields + 2) def assertHateoasLinks(self, links, version_param): - """ Makes sure links for `self`, `collection`, and `parent` point to + """Makes sure links for `self`, `collection`, and `parent` point to the right place. """ - self_url = links['self']['href'] - coll_url = links['collection']['href'] - prnt_url = links['parent']['href'] - self.assertTrue('?version=%s' % (str(version_param)) in self_url) - if version_param in ('all', 'diffs'): - self.assertEqual(self_url.split('?')[0], coll_url) - self.assertEqual(coll_url.rsplit('/', 1)[0], prnt_url) + self_url = links["self"]["href"] + coll_url = links["collection"]["href"] + prnt_url = links["parent"]["href"] + self.assertTrue("?version=%s" % (str(version_param)) in self_url) + if version_param in ("all", "diffs"): + self.assertEqual(self_url.split("?")[0], coll_url) + self.assertEqual(coll_url.rsplit("/", 1)[0], prnt_url) else: - self.assertEqual('%s?version=all' % self_url.split('?')[0], - coll_url) - self.assertEqual(coll_url.split('?')[0], prnt_url) + self.assertEqual("%s?version=all" % self_url.split("?")[0], coll_url) + self.assertEqual(coll_url.split("?")[0], prnt_url) def do_test_get(self): - query = '?where={"%s":"%s"}' % \ - (self.id_field, self.item_id) + query = '?where={"%s":"%s"}' % (self.id_field, self.item_id) response, status = self.get(self.known_resource, query=query) - response = response[self.app.config['ITEMS']][0] + response = response[self.app.config["ITEMS"]][0] # get always returns the latest version of a document self.assert200(status) @@ -221,34 +219,38 @@ def do_test_get(self): def do_test_getitem(self, partial): # put a second version - response, status = self.put(self.item_id_url, data=self.item_change, - headers=[('If-Match', self.item_etag)]) + response, status = self.put( + self.item_id_url, + data=self.item_change, + headers=[("If-Match", self.item_etag)], + ) self.assertGoodPutPatch(response, status) if partial is True: # build expected response since the state of version 1 will change version_1 = copy.copy(self.item) - version_1[self.unversioned_field] = \ - self.item_change[self.unversioned_field] + version_1[self.unversioned_field] = self.item_change[self.unversioned_field] else: version_1 = self.item # check the get of the first version - response, status = self.get(self.known_resource, item=self.item_id, - query='?version=1') + response, status = self.get( + self.known_resource, item=self.item_id, query="?version=1" + ) self.assert200(status) self.assertDocumentVersionFields(response, 1, 2) self.assertEqualFields(version_1, response, self.fields) - links = response['_links'] + links = response["_links"] self.assertHateoasLinks(links, 1) # check the get of the second version - response, status = self.get(self.known_resource, item=self.item_id, - query='?version=2') + response, status = self.get( + self.known_resource, item=self.item_id, query="?version=2" + ) self.assert200(status) self.assertDocumentVersionFields(response, 2) self.assertEqualFields(self.item_change, response, self.fields) - links = response['_links'] + links = response["_links"] self.assertHateoasLinks(links, 2) # check the get without version specified and make sure it is version 2 @@ -258,8 +260,7 @@ def do_test_getitem(self, partial): self.assertEqualFields(self.item_change, response, self.fields) def do_test_post(self, partial): - response, status = self.post( - self.known_resource_url, data=self.item_change) + response, status = self.post(self.known_resource_url, data=self.item_change) self.assert201(status) _id = response[self.id_field] self.assertPrimaryAndShadowDocuments(_id, 1, partial=partial) @@ -273,8 +274,11 @@ def do_test_multi_post(self): self.assertTrue(True) def do_test_put(self, partial): - response, status = self.put(self.item_id_url, data=self.item_change, - headers=[('If-Match', self.item_etag)]) + response, status = self.put( + self.item_id_url, + data=self.item_change, + headers=[("If-Match", self.item_etag)], + ) self.assertGoodPutPatch(response, status) self.assertPrimaryAndShadowDocuments(self.item_id, 2, partial=partial) @@ -285,8 +289,10 @@ def do_test_put(self, partial): def do_test_patch(self, partial): response, status = self.patch( - self.item_id_url, data=self.item_change, - headers=[('If-Match', self.item_etag)]) + self.item_id_url, + data=self.item_change, + headers=[("If-Match", self.item_etag)], + ) self.assertGoodPutPatch(response, status) self.assertPrimaryAndShadowDocuments(self.item_id, 2, partial=partial) @@ -301,87 +307,92 @@ def do_test_version_control_the_unkown(self): class TestCompleteVersioning(TestNormalVersioning): def setUp(self): - super(TestCompleteVersioning, self).setUp() + super().setUp() self.enableVersioning() self.insertTestData() def test_get(self): - """ - """ + """""" self.do_test_get() def test_getitem(self): - """ - """ + """""" self.do_test_getitem(partial=False) def test_post(self): - """ Verify that a shadow document is created on post with all of the + """Verify that a shadow document is created on post with all of the appropriate fields. """ self.do_test_post(partial=False) def test_multi_post(self): - """ Eve literally throws single documents into an array before + """Eve literally throws single documents into an array before processing them in a POST, so I don't feel the need to specially test the versioning features here. Making a stub nontheless. """ self.do_test_multi_post() def test_put(self): - """ Verify that an additional shadow document is created on post with + """Verify that an additional shadow document is created on post with all of the appropriate fields. """ self.do_test_put(partial=False) def test_patch(self): - """ - """ + """""" self.do_test_patch(partial=False) def test_version_control_the_unkown(self): - """ - """ + """""" self.do_test_version_control_the_unkown() def test_getitem_version_unknown(self): - """ Make sure that Eve return a nice error when requesting an unknown + """Make sure that Eve return a nice error when requesting an unknown version. """ response, status = self.get( - self.known_resource, item=self.item_id, query='?version=2') + self.known_resource, item=self.item_id, query="?version=2" + ) self.assert404(status) def test_getitem_version_bad_format(self): - """ Make sure that Eve return a nice error when requesting an unknown + """Make sure that Eve return a nice error when requesting an unknown version. """ response, status = self.get( - self.known_resource, item=self.item_id, query='?version=bad') + self.known_resource, item=self.item_id, query="?version=bad" + ) self.assert400(status) def test_getitem_version_all(self): - """ Verify that all documents are returned which each appearing exactly + """Verify that all documents are returned which each appearing exactly as it would if it were accessed explicitly. """ meta_fields = self.fields + [ self.id_field, - self.app.config['LAST_UPDATED'], self.app.config['ETAG'], - self.app.config['DATE_CREATED'], self.app.config['LINKS'], - self.version_field, self.latest_version_field] + self.app.config["LAST_UPDATED"], + self.app.config["ETAG"], + self.app.config["DATE_CREATED"], + self.app.config["LINKS"], + self.version_field, + self.latest_version_field, + ] # put a second version response, status = self.put( - self.item_id_url, data=self.item_change, - headers=[('If-Match', self.item_etag)]) + self.item_id_url, + data=self.item_change, + headers=[("If-Match", self.item_etag)], + ) self.assertGoodPutPatch(response, status) - etag2 = response[self.app.config['ETAG']] + etag2 = response[self.app.config["ETAG"]] # get query response, status = self.get( - self.known_resource, item=self.item_id, query='?version=all') + self.known_resource, item=self.item_id, query="?version=all" + ) self.assert200(status) - items = response[self.app.config['ITEMS']] + items = response[self.app.config["ITEMS"]] self.assertEqual(len(items), 2) # check the get of the first version @@ -389,133 +400,204 @@ def test_getitem_version_all(self): self.assertEqualFields(self.item, items[0], self.fields) self.assertTrue(field in items[0] for field in meta_fields) self.assertEqual(len(items[0].keys()), len(meta_fields)) - self.assertEqual(items[0][self.app.config['ETAG']], self.item_etag) + self.assertEqual(items[0][self.app.config["ETAG"]], self.item_etag) # # check the get of the second version self.assertDocumentVersionFields(items[1], 2) self.assertEqualFields(self.item_change, items[1], self.fields) self.assertTrue(field in items[1] for field in meta_fields) self.assertEqual(len(items[1].keys()), len(meta_fields)) - self.assertEqual(items[1][self.app.config['ETAG']], etag2) + self.assertEqual(items[1][self.app.config["ETAG"]], etag2) # check the `self` links for both versions - self_href = items[0]['_links']['self']['href'] - self.assertEqual(int(self_href.split('?version=')[1]), - items[0][self.version_field]) - self_href = items[1]['_links']['self']['href'] - self.assertEqual(int(self_href.split('?version=')[1]), - items[1][self.version_field]) + self_href = items[0]["_links"]["self"]["href"] + self.assertEqual( + int(self_href.split("?version=")[1]), items[0][self.version_field] + ) + self_href = items[1]["_links"]["self"]["href"] + self.assertEqual( + int(self_href.split("?version=")[1]), items[1][self.version_field] + ) def test_getitem_version_pagination(self): - """ Verify that `?version=all` and `?version=diffs` display pagination + """Verify that `?version=all` and `?version=diffs` display pagination links when results exceed `PAGINATION_DEFAULT`. """ # create many versions response, status = self.put( - self.item_id_url, data=self.item_change, - headers=[('If-Match', self.item_etag)]) + self.item_id_url, + data=self.item_change, + headers=[("If-Match", self.item_etag)], + ) for n in range(100): response, status = self.put( - self.item_id_url, data=self.item_change, - headers=[('If-Match', response[self.app.config['ETAG']])]) + self.item_id_url, + data=self.item_change, + headers=[("If-Match", response[self.app.config["ETAG"]])], + ) # get 2nd page of results page = 2 - response, status = self.get(self.known_resource, item=self.item_id, - query='?version=all&page=%d' % page) - links = response['_links'] + response, status = self.get( + self.known_resource, item=self.item_id, query="?version=all&page=%d" % page + ) + links = response["_links"] self.assertNextLink(links, 3) self.assertPrevLink(links, 1) self.assertLastLink(links, 5) self.assertPagination(response, 2, 102, 25) - self.assertHateoasLinks(links, 'all') + self.assertHateoasLinks(links, "all") def test_on_fetched_item(self): - """ Verify that on_fetched_item events are fired for versioned + """Verify that on_fetched_item events are fired for versioned requests. """ devent = DummyEvent(lambda: True) self.app.on_fetched_item += devent - response, status = self.get(self.known_resource, item=self.item_id, - query='?version=1') + response, status = self.get( + self.known_resource, item=self.item_id, query="?version=1" + ) self.assertEqual(self.known_resource, devent.called[0]) - self.assertEqual( - self.item_id, - str(devent.called[1][self.id_field])) + self.assertEqual(self.item_id, str(devent.called[1][self.id_field])) self.assertEqual(2, len(devent.called)) # check for ?version=all requests devent = DummyEvent(lambda: True) self.app.on_fetched_item += devent - response, status = self.get(self.known_resource, item=self.item_id, - query='?version=all') + response, status = self.get( + self.known_resource, item=self.item_id, query="?version=all" + ) self.assertEqual(self.known_resource, devent.called[0]) - self.assertEqual( - self.item_id, - str(devent.called[1][self.id_field])) + self.assertEqual(self.item_id, str(devent.called[1][self.id_field])) self.assertEqual(2, len(devent.called)) # check for ?version=diffs requests devent = DummyEvent(lambda: True) self.app.on_fetched_item += devent - response, status = self.get(self.known_resource, item=self.item_id, - query='?version=diffs') + response, status = self.get( + self.known_resource, item=self.item_id, query="?version=diffs" + ) self.assertEqual(None, devent.called) def test_on_fetched_item_contacts(self): - """ Verify that on_fetched_item_contacts events are fired for versioned + """Verify that on_fetched_item_contacts events are fired for versioned requests. """ devent = DummyEvent(lambda: True) self.app.on_fetched_item_contacts += devent - response, status = self.get(self.known_resource, item=self.item_id, - query='?version=1') - self.assertEqual( - self.item_id, - str(devent.called[0][self.id_field])) + response, status = self.get( + self.known_resource, item=self.item_id, query="?version=1" + ) + self.assertEqual(self.item_id, str(devent.called[0][self.id_field])) self.assertEqual(1, len(devent.called)) # check for ?version=all requests devent = DummyEvent(lambda: True) self.app.on_fetched_item_contacts += devent - response, status = self.get(self.known_resource, item=self.item_id, - query='?version=all') - self.assertEqual( - self.item_id, - str(devent.called[0][self.id_field])) + response, status = self.get( + self.known_resource, item=self.item_id, query="?version=all" + ) + self.assertEqual(self.item_id, str(devent.called[0][self.id_field])) self.assertEqual(1, len(devent.called)) # check for ?version=diffs requests devent = DummyEvent(lambda: True) self.app.on_fetched_item_contacts += devent - response, status = self.get(self.known_resource, item=self.item_id, - query='?version=diffs') + response, status = self.get( + self.known_resource, item=self.item_id, query="?version=diffs" + ) + self.assertEqual(None, devent.called) + + # TODO: also test with HATEOS off + + def test_on_fetched_diffs(self): + """Verify that on_fetched_item events are fired for + version=diffs requests. + """ + devent = DummyEvent(lambda: True) + self.app.on_fetched_diffs += devent + response, status = self.get( + self.known_resource, item=self.item_id, query="?version=1" + ) + self.assertEqual(None, devent.called) + + # check for ?version=all requests + devent = DummyEvent(lambda: True) + self.app.on_fetched_diffs += devent + response, status = self.get( + self.known_resource, item=self.item_id, query="?version=all" + ) + self.assertEqual(None, devent.called) + + # check for ?version=diffs requests + devent = DummyEvent(lambda: True) + self.app.on_fetched_diffs += devent + response, status = self.get( + self.known_resource, item=self.item_id, query="?version=diffs" + ) + self.assertEqual(self.known_resource, devent.called[0]) + self.assertEqual(2, len(devent.called)) + + def test_on_fetched_diffs_contacts(self): + """Verify that on_fetched_diffs_contacts events are fired for + version=diffs requests. + """ + devent = DummyEvent(lambda: True) + self.app.on_fetched_diffs_contacts += devent + response, status = self.get( + self.known_resource, item=self.item_id, query="?version=1" + ) + self.assertEqual(None, devent.called) + + # check for ?version=all requests + devent = DummyEvent(lambda: True) + self.app.on_fetched_diffs_contacts += devent + response, status = self.get( + self.known_resource, item=self.item_id, query="?version=all" + ) self.assertEqual(None, devent.called) + # check for ?version=diffs requests + devent = DummyEvent(lambda: True) + self.app.on_fetched_diffs_contacts += devent + response, status = self.get( + self.known_resource, item=self.item_id, query="?version=diffs" + ) + # Verify first document has id_field + self.assertEqual(self.item_id, str(devent.called[0][0][self.id_field])) + self.assertEqual(1, len(devent.called)) + # TODO: also test with HATEOS off def test_getitem_version_diffs(self): - """ Verify that the first document is returned in its entirety and that + """Verify that the first document is returned in its entirety and that subsequent documents are simply diff to the previous version. """ meta_fields = self.fields + [ self.id_field, - self.app.config['LAST_UPDATED'], self.app.config['ETAG'], - self.app.config['DATE_CREATED'], self.app.config['LINKS'], - self.version_field, self.latest_version_field] + self.app.config["LAST_UPDATED"], + self.app.config["ETAG"], + self.app.config["DATE_CREATED"], + self.app.config["LINKS"], + self.version_field, + self.latest_version_field, + ] # put a second version response, status = self.put( - self.item_id_url, data=self.item_change, - headers=[('If-Match', self.item_etag)]) + self.item_id_url, + data=self.item_change, + headers=[("If-Match", self.item_etag)], + ) self.assertGoodPutPatch(response, status) - etag2 = response[self.app.config['ETAG']] + etag2 = response[self.app.config["ETAG"]] # get query response, status = self.get( - self.known_resource, item=self.item_id, query='?version=diffs') + self.known_resource, item=self.item_id, query="?version=diffs" + ) self.assert200(status) - items = response[self.app.config['ITEMS']] + items = response[self.app.config["ITEMS"]] self.assertEqual(len(items), 2) # check the get of the first version @@ -523,22 +605,25 @@ def test_getitem_version_diffs(self): self.assertEqualFields(self.item, items[0], self.fields) self.assertTrue(field in items[0] for field in meta_fields) self.assertEqual(len(items[0].keys()), len(meta_fields)) - self.assertEqual(items[0][self.app.config['ETAG']], self.item_etag) + self.assertEqual(items[0][self.app.config["ETAG"]], self.item_etag) # # check the get of the second version self.assertVersion(items[1], 2) self.assertEqualFields(self.item_change, items[1], self.fields) changed_fields = self.fields + [ self.version_field, - self.app.config['ETAG']] + self.app.config["ETAG"], + self.app.config["LINKS"], + ] for field in changed_fields: self.assertTrue(field in items[1], "%s not in diffs" % field) # since the test routine happens so fast, `LAST_UPDATED` may or may not # be in the diff (the date output only has a one second resolution) self.assertTrue( - len(items[1].keys()) == len(changed_fields) or - len(items[1].keys()) == len(changed_fields) + 1) - self.assertEqual(items[1][self.app.config['ETAG']], etag2) + len(items[1].keys()) == len(changed_fields) + or len(items[1].keys()) == len(changed_fields) + 1 + ) + self.assertEqual(items[1][self.app.config["ETAG"]], etag2) # TODO: could also verify that a 3rd iteration is a diff of the 2nd # iteration and not a diff of the 1st iteration by mistake... @@ -546,12 +631,13 @@ def test_getitem_version_diffs(self): # TODO: also test with HATEOS off def test_getitem_projection(self): - """ Verify that projections happen smoothly when versioning is on. - """ + """Verify that projections happen smoothly when versioning is on.""" # test inclusive projection response, status = self.get( - self.known_resource, item=self.item_id, - query='?projection={"%s": 1}' % self.unversioned_field) + self.known_resource, + item=self.item_id, + query='?projection={"%s": 1}' % self.unversioned_field, + ) self.assert200(status) self.assertTrue(self.unversioned_field in response) self.assertFalse(self.versioned_field in response) @@ -560,8 +646,10 @@ def test_getitem_projection(self): # test exclusive projection response, status = self.get( - self.known_resource, item=self.item_id, - query='?projection={"%s": 0}' % self.unversioned_field) + self.known_resource, + item=self.item_id, + query='?projection={"%s": 0}' % self.unversioned_field, + ) self.assert200(status) self.assertFalse(self.unversioned_field in response) self.assertTrue(self.versioned_field in response) @@ -569,21 +657,24 @@ def test_getitem_projection(self): self.assertTrue(self.latest_version_field in response) def test_getitem_version_all_projection(self): - """ Verify that projections happen smoothly when versioning is on. - """ + """Verify that projections happen smoothly when versioning is on.""" # put a second version response, status = self.put( - self.item_id_url, data=self.item_change, - headers=[('If-Match', self.item_etag)]) + self.item_id_url, + data=self.item_change, + headers=[("If-Match", self.item_etag)], + ) self.assertGoodPutPatch(response, status) # test inclusive projection projection = '{"%s": 1}' % self.unversioned_field response, status = self.get( - self.known_resource, item=self.item_id, - query='?version=all&projection=%s' % projection) + self.known_resource, + item=self.item_id, + query="?version=all&projection=%s" % projection, + ) self.assert200(status) - items = response[self.app.config['ITEMS']] + items = response[self.app.config["ITEMS"]] self.assertEqual(len(items), 2) for item in items: self.assertTrue(self.unversioned_field in item) @@ -592,20 +683,23 @@ def test_getitem_version_all_projection(self): self.assertTrue(self.latest_version_field in item) if item[self.version_field] == 1: self.assertEqual( - item[self.unversioned_field], - self.item[self.unversioned_field]) + item[self.unversioned_field], self.item[self.unversioned_field] + ) else: self.assertEqual( item[self.unversioned_field], - self.item_change[self.unversioned_field]) + self.item_change[self.unversioned_field], + ) # test exclusive projection projection = '{"%s": 0}' % self.unversioned_field response, status = self.get( - self.known_resource, item=self.item_id, - query='?version=all&projection=%s' % projection) + self.known_resource, + item=self.item_id, + query="?version=all&projection=%s" % projection, + ) self.assert200(status) - items = response[self.app.config['ITEMS']] + items = response[self.app.config["ITEMS"]] self.assertEqual(len(items), 2) for item in items: self.assertFalse(self.unversioned_field in item) @@ -613,8 +707,7 @@ def test_getitem_version_all_projection(self): self.assertTrue(self.version_field in item) self.assertTrue(self.latest_version_field in item) - def test_getitem_version_new_latest_version_invalidates_if_modified_since( - self): + def test_getitem_version_new_latest_version_invalidates_if_modified_since(self): """Verify that a cached document version is invalidated via an 'If-Modified-Since' header when the _latest_version field has changed due to creation of a new version @@ -624,26 +717,29 @@ def test_getitem_version_new_latest_version_invalidates_if_modified_since( document, status = self.parse_response(r) self.assert200(status) self.assertEqual(document[self.latest_version_field], 1) - last_modified = r.headers.get('Last-Modified') + last_modified = r.headers.get("Last-Modified") # put a second version (after enough time has passed to expect a new # Last-Modified header) time.sleep(2) response, status = self.put( - self.item_id_url, data=self.item_change, - headers=[('If-Match', self.item_etag)]) + self.item_id_url, + data=self.item_change, + headers=[("If-Match", self.item_etag)], + ) self.assertGoodPutPatch(response, status) # get first version again and confirm Last-Modified and latest version # have been updated - r = self.test_client.get(self.item_id_url + "?version=1", headers=[ - ('If-Modified-Since', last_modified)]) + r = self.test_client.get( + self.item_id_url + "?version=1", + headers=[("If-Modified-Since", last_modified)], + ) document, status = self.parse_response(r) self.assert200(status) self.assertEqual(document[self.latest_version_field], 2) - def test_getitem_version_new_latest_version_invalidates_if_none_match( - self): + def test_getitem_version_new_latest_version_invalidates_if_none_match(self): """Verify that a cached document version is invalidated via an 'If-None-Match' header when the _latest_version field has changed due to creation of a new version @@ -653,71 +749,70 @@ def test_getitem_version_new_latest_version_invalidates_if_none_match( document, status = self.parse_response(r) self.assert200(status) self.assertEqual(document[self.latest_version_field], 1) - version1_etag = r.headers.get('ETag') + version1_etag = r.headers.get("ETag") # put a second version response, status = self.put( - self.item_id_url, data=self.item_change, - headers=[('If-Match', self.item_etag)]) + self.item_id_url, + data=self.item_change, + headers=[("If-Match", self.item_etag)], + ) self.assertGoodPutPatch(response, status) # get first version again and confirm latest version has been updated - r = self.test_client.get(self.item_id_url + "?version=1", headers=[ - ('If-None-Match', version1_etag) - ]) + r = self.test_client.get( + self.item_id_url + "?version=1", headers=[("If-None-Match", version1_etag)] + ) document, status = self.parse_response(r) self.assert200(status) self.assertEqual(document[self.latest_version_field], 2) def test_automatic_fields(self): - """ Make sure that Eve throws an error if we try to set a versioning + """Make sure that Eve throws an error if we try to set a versioning field manually. """ # set _version - self.item_change[self.version_field] = '1' - r, status = self.post( - self.known_resource_url, data=self.item_change) + self.item_change[self.version_field] = "1" + r, status = self.post(self.known_resource_url, data=self.item_change) self.assertValidationErrorStatus(status) - self.assertValidationError(r, {self.version_field: 'unknown field'}) + self.assertValidationError(r, {self.version_field: "unknown field"}) # set _latest_version - self.item_change[self.latest_version_field] = '1' - r, status = self.post( - self.known_resource_url, data=self.item_change) + self.item_change[self.latest_version_field] = "1" + r, status = self.post(self.known_resource_url, data=self.item_change) self.assertValidationErrorStatus(status) - self.assertValidationError( - r, {self.latest_version_field: 'unknown field'}) + self.assertValidationError(r, {self.latest_version_field: "unknown field"}) # set _id_document - self.item_change[self.document_id_field] = '1' - r, status = self.post( - self.known_resource_url, data=self.item_change) + self.item_change[self.document_id_field] = "1" + r, status = self.post(self.known_resource_url, data=self.item_change) self.assertValidationErrorStatus(status) - self.assertValidationError( - r, {self.document_id_field: 'unknown field'}) + self.assertValidationError(r, {self.document_id_field: "unknown field"}) def test_referential_integrity(self): - """ Make sure that Eve still correctly handles vanilla data_relations + """Make sure that Eve still correctly handles vanilla data_relations when versioning is turned on. (Copied from tests/methods/post.py.) """ data = {"person": self.unknown_item_id} - r, status = self.post('/invoices/', data=data) + r, status = self.post("/invoices/", data=data) self.assertValidationErrorStatus(status) - expected = ("value '%s' must exist in resource '%s', field '%s'" % - (self.unknown_item_id, 'contacts', - self.id_field)) - self.assertValidationError(r, {'person': expected}) + expected = "value '%s' must exist in resource '%s', field '%s'" % ( + self.unknown_item_id, + "contacts", + self.id_field, + ) + self.assertValidationError(r, {"person": expected}) data = {"person": self.item_id} - r, status = self.post('/invoices/', data=data) + r, status = self.post("/invoices/", data=data) self.assert201(status) def test_delete(self): - """ Verify that we don't throw an error if we delete a resource that is + """Verify that we don't throw an error if we delete a resource that is supposed to be versioned but whose shadow collection does not exist. """ # turn off filter setting - self.domain['contacts']['datasource']['filter'] = None + self.domain["contacts"]["datasource"]["filter"] = None # verify the primary collection exists but the shadow does not self.assertTrue(self.countDocuments() > 0) @@ -732,7 +827,7 @@ def test_delete(self): self.assertTrue(self.countShadowDocuments() == 0) def test_deleteitem(self): - """ Verify that we don't throw an error if we delete an item that is + """Verify that we don't throw an error if we delete an item that is supposed to be versioned but that doesn't have any shadow copies. """ # verify the primary document exists but no shadow documents do @@ -741,7 +836,8 @@ def test_deleteitem(self): # delete resource and verify no errors response, status = self.delete( - self.item_id_url, headers=[('If-Match', self.item_etag)]) + self.item_id_url, headers=[("If-Match", self.item_etag)] + ) self.assert204(status) # verify that neither primary or shadow documents exist @@ -749,7 +845,7 @@ def test_deleteitem(self): self.assertTrue(self.countShadowDocuments(self.item_id) == 0) def test_softdelete(self): - """ Deleting a versioned item with soft delete enabled should create a + """Deleting a versioned item with soft delete enabled should create a new version marked as deleted, which is returned with 404 Not Found in response to GET requests. GETs of previous versions should continue to respond with `200 OK` responses. Requests for `?version=all/diff` @@ -758,7 +854,8 @@ def test_softdelete(self): """ self.enableSoftDelete() response, status = self.delete( - self.item_id_url, headers=[('If-Match', self.item_etag)]) + self.item_id_url, headers=[("If-Match", self.item_etag)] + ) self.assert204(status) # verify that the primary document and two (v1 and deleted v2) shadow @@ -792,14 +889,14 @@ def test_softdelete(self): r = self.test_client.get(self.item_id_url + "?version=all") document, status = self.parse_response(r) self.assert200(status) - items = document[self.app.config['ITEMS']] + items = document[self.app.config["ITEMS"]] self.assertEqual(len(items), 2) self.assertEqual(items[1].get(self.deleted_field), True) r = self.test_client.get(self.item_id_url + "?version=diffs") document, status = self.parse_response(r) self.assert200(status) - items = document[self.app.config['ITEMS']] + items = document[self.app.config["ITEMS"]] self.assertEqual(len(items), 2) # Deleted item shoud diff by the version, etag, deleted, links, and # last_updated field only (the speed of test executon means @@ -808,56 +905,56 @@ def test_softdelete(self): changed_fields = [ self.version_field, self.deleted_field, - self.app.config['ETAG'], - self.app.config['LINKS']] + self.app.config["ETAG"], + self.app.config["LINKS"], + ] self.assertTrue( - len(items[1].keys()) == len(changed_fields) or - len(items[1].keys()) == len(changed_fields) + 1) + len(items[1].keys()) == len(changed_fields) + or len(items[1].keys()) == len(changed_fields) + 1 + ) for field in changed_fields: self.assertTrue(field in items[1], "%s not in diffs" % field) def test_softdelete_version_db_fields(self): - """ Document versions created with soft delete enabled should include + """Document versions created with soft delete enabled should include the DELETED field. """ self.enableSoftDelete() # v1 created before soft delete was enabled, it will not have DELETED - v1_doc = self._db[self.known_resource_shadow].find_one({ - self.document_id_field: ObjectId(self.item_id), - self.version_field: 1 - }) + v1_doc = self._db[self.known_resource_shadow].find_one( + {self.document_id_field: ObjectId(self.item_id), self.version_field: 1} + ) self.assertEqual(v1_doc.get(self.deleted_field), None) # Create second version r = self.test_client.patch( self.item_id_url, - data={'ref': '1234567890123456789012345'}, - headers=[('If-Match', self.item_etag)] + data={"ref": "1234567890123456789012345"}, + headers=[("If-Match", self.item_etag)], ) # Create deleted third version response, status = self.delete( - self.item_id_url, headers=[('If-Match', r.headers['ETag'])]) + self.item_id_url, headers=[("If-Match", r.headers["ETag"])] + ) self.assert204(status) # v2 doc should have DELETED = False added - v2_doc = self._db[self.known_resource_shadow].find_one({ - self.document_id_field: ObjectId(self.item_id), - self.version_field: 2 - }) + v2_doc = self._db[self.known_resource_shadow].find_one( + {self.document_id_field: ObjectId(self.item_id), self.version_field: 2} + ) self.assertEqual(v2_doc.get(self.deleted_field), False) # v3 should have DELETED = True - v3_doc = self._db[self.known_resource_shadow].find_one({ - self.document_id_field: ObjectId(self.item_id), - self.version_field: 3 - }) + v3_doc = self._db[self.known_resource_shadow].find_one( + {self.document_id_field: ObjectId(self.item_id), self.version_field: 3} + ) self.assertEqual(v3_doc.get(self.deleted_field), True) class TestVersionedDataRelation(TestNormalVersioning): def setUp(self): - super(TestVersionedDataRelation, self).setUp() + super().setUp() # enable versioning in the invoice data_relation definition self.enableDataVersionRelation() @@ -866,145 +963,161 @@ def setUp(self): self.insertTestData() def test_referential_integrity(self): - """ Make sure that Eve correctly validates a data_relation with a + """Make sure that Eve correctly validates a data_relation with a version and returns the version with the data_relation in the response. """ - data_relation = \ - self.domain['invoices']['schema']['person']['data_relation'] - value_field = data_relation['field'] - version_field = self.app.config['VERSION'] + data_relation = self.domain["invoices"]["schema"]["person"]["data_relation"] + value_field = data_relation["field"] + version_field = self.app.config["VERSION"] validation_error_format = ( "versioned data_relation must be a dict" - " with fields '%s' and '%s'" % (value_field, version_field)) + " with fields '%s' and '%s'" % (value_field, version_field) + ) # must be a dict data = {"person": self.item_id} - r, status = self.post('/invoices/', data=data) + r, status = self.post("/invoices/", data=data) self.assertValidationErrorStatus(status) - self.assertValidationError(r, {'person': 'must be of dict type'}) + self.assertValidationError(r, {"person": "must be of dict type"}) # must have _id data = {"person": {value_field: self.item_id}} - r, status = self.post('/invoices/', data=data) + r, status = self.post("/invoices/", data=data) self.assertValidationErrorStatus(status) - self.assertValidationError(r, {'person': validation_error_format}) + self.assertValidationError(r, {"person": validation_error_format}) # must have _version data = {"person": {version_field: 1}} - r, status = self.post('/invoices/', data=data) + r, status = self.post("/invoices/", data=data) self.assertValidationErrorStatus(status) - self.assertValidationError(r, {'person': validation_error_format}) + self.assertValidationError(r, {"person": validation_error_format}) # bad id format - data = {"person": {value_field: 'bad', version_field: 1}} - r, status = self.post('/invoices/', data=data) + data = {"person": {value_field: "bad", version_field: 1}} + r, status = self.post("/invoices/", data=data) self.assertValidationErrorStatus(status) self.assertValidationError( - r, {'person': { - value_field: "value 'bad' cannot be converted to a ObjectId"}}) + r, {"person": {value_field: "must be of objectid type"}} + ) # unknown id - data = {"person": { - value_field: self.unknown_item_id, version_field: 1}} - r, status = self.post('/invoices/', data=data) + data = {"person": {value_field: self.unknown_item_id, version_field: 1}} + r, status = self.post("/invoices/", data=data) self.assertValidationErrorStatus(status) self.assertValidationError( - r, {'person': "value '%s' must exist in " - "resource '%s', field '%s' at version '%s'." % - (self.unknown_item_id, 'contacts', value_field, 1)}) + r, + { + "person": "value '%s' must exist in " + "resource '%s', field '%s' at version '%s'." + % (self.unknown_item_id, "contacts", value_field, 1) + }, + ) # version doesn't exist data = {"person": {value_field: self.item_id, version_field: 2}} - r, status = self.post('/invoices/', data=data) + r, status = self.post("/invoices/", data=data) self.assertValidationErrorStatus(status) self.assertValidationError( - r, {'person': "value '%s' must exist in " - "resource '%s', field '%s' at version '%s'." % - (self.item_id, 'contacts', value_field, 2)}) + r, + { + "person": "value '%s' must exist in " + "resource '%s', field '%s' at version '%s'." + % (self.item_id, "contacts", value_field, 2) + }, + ) # put a second version - response, status = self.put(self.item_id_url, data=self.item_change, - headers=[('If-Match', self.item_etag)]) + response, status = self.put( + self.item_id_url, + data=self.item_change, + headers=[("If-Match", self.item_etag)], + ) self.assertGoodPutPatch(response, status) # reference first version... this should work data = {"person": {value_field: self.item_id, version_field: 1}} - r, status = self.post('/invoices/', data=data) + r, status = self.post("/invoices/", data=data) self.assert201(status) # and response should include embedded v1 response, status = self.get( - self.domain['invoices']['url'], + self.domain["invoices"]["url"], item=r[self.id_field], - query='?embedded={"person": 1}') + query='?embedded={"person": 1}', + ) self.assert200(status) - self.assertEqual(response['person'].get(version_field), 1) + self.assertEqual(response["person"].get(version_field), 1) # reference second version... this should work data = {"person": {value_field: self.item_id, version_field: 2}} - r, status = self.post('/invoices/', data=data) + r, status = self.post("/invoices/", data=data) self.assert201(status) # and response should include embedded v2 response, status = self.get( - self.domain['invoices']['url'], + self.domain["invoices"]["url"], item=r[self.id_field], - query='?embedded={"person": 1}') + query='?embedded={"person": 1}', + ) self.assert200(status) - self.assertEqual(response['person'].get(version_field), 2) + self.assertEqual(response["person"].get(version_field), 2) def test_embedded(self): - """ Perform a quick check to make sure that Eve can embedded with a + """Perform a quick check to make sure that Eve can embedded with a version in the data relation. """ - data_relation = \ - self.domain['invoices']['schema']['person']['data_relation'] - value_field = data_relation['field'] + data_relation = self.domain["invoices"]["schema"]["person"]["data_relation"] + value_field = data_relation["field"] # add embeddable data relation data = {"person": {value_field: self.item_id, self.version_field: 1}} - response, status = self.post('/invoices/', data=data) + response, status = self.post("/invoices/", data=data) self.assert201(status) invoice_id = response[value_field] # test that it works response, status = self.get( - self.domain['invoices']['url'], - item=invoice_id, query='?embedded={"person": 1}') + self.domain["invoices"]["url"], + item=invoice_id, + query='?embedded={"person": 1}', + ) self.assert200(status) - self.assertTrue('ref' in response['person']) + self.assertTrue("ref" in response["person"]) def test_softdelete_embedded(self): - """ If a versioned embedded document is soft deleted, a previous + """If a versioned embedded document is soft deleted, a previous version should still resolve correctly. """ self.enableSoftDelete() - data_relation = \ - self.domain['invoices']['schema']['person']['data_relation'] - value_field = data_relation['field'] - version_field = self.app.config['VERSION'] + data_relation = self.domain["invoices"]["schema"]["person"]["data_relation"] + value_field = data_relation["field"] + version_field = self.app.config["VERSION"] # add embeddable data relation data = {"person": {value_field: self.item_id, version_field: 1}} - response, status = self.post('/invoices/', data=data) + response, status = self.post("/invoices/", data=data) self.assert201(status) invoice_id = response[value_field] # soft delete embedded doc response, status = self.delete( - self.item_id_url, headers=[('If-Match', self.item_etag)]) + self.item_id_url, headers=[("If-Match", self.item_etag)] + ) self.assert204(status) # v1 should still return response, status = self.get( - self.domain['invoices']['url'], - item=invoice_id, query='?embedded={"person": 1}') + self.domain["invoices"]["url"], + item=invoice_id, + query='?embedded={"person": 1}', + ) self.assert200(status) - self.assertEqual(response['person'].get(self.id_field), self.item_id) - self.assertEqual(response['person'].get( - self.app.config['ETAG']), self.item_etag) - self.assertEqual(response['person'].get(self.version_field), 1) - self.assertEqual(response['person'].get(self.deleted_field), False) + self.assertEqual(response["person"].get(self.id_field), self.item_id) + self.assertEqual( + response["person"].get(self.app.config["ETAG"]), self.item_etag + ) + self.assertEqual(response["person"].get(self.version_field), 1) + self.assertEqual(response["person"].get(self.deleted_field), False) def test_softdelete_data_relation_validation(self): """Eve validation should not allow a data relation to a soft deleted @@ -1016,32 +1129,36 @@ def test_softdelete_data_relation_validation(self): # soft delete embeddable document self.enableSoftDelete() response, status = self.delete( - self.item_id_url, headers=[('If-Match', self.item_etag)]) + self.item_id_url, headers=[("If-Match", self.item_etag)] + ) self.assert204(status) # creating data relation to still valid v1 should work - data_relation = \ - self.domain['invoices']['schema']['person']['data_relation'] - value_field = data_relation['field'] - version_field = self.app.config['VERSION'] + data_relation = self.domain["invoices"]["schema"]["person"]["data_relation"] + value_field = data_relation["field"] + version_field = self.app.config["VERSION"] data = {"person": {value_field: self.item_id, version_field: 1}} - response, status = self.post('/invoices/', data=data) + response, status = self.post("/invoices/", data=data) self.assert201(status) # saving relation to deleted version 2 should fail data = {"person": {value_field: self.item_id, version_field: 2}} - r, status = self.post('/invoices/', data=data) + r, status = self.post("/invoices/", data=data) self.assertValidationErrorStatus(status) self.assertValidationError( - r, {'person': "value '%s' must exist in " - "resource '%s', field '%s' at version '%s'." % - (self.item_id, 'contacts', value_field, 2)}) + r, + { + "person": "value '%s' must exist in " + "resource '%s', field '%s' at version '%s'." + % (self.item_id, "contacts", value_field, 2) + }, + ) class TestVersionedDataRelationCustomField(TestNormalVersioning): def setUp(self): - super(TestVersionedDataRelationCustomField, self).setUp() + super().setUp() # enable versioning in the invoice data_relation definition with custom # relation field @@ -1051,117 +1168,129 @@ def setUp(self): self.insertTestData() def test_referential_integrity(self): - """ Make sure that Eve correctly distinguishes between versions when + """Make sure that Eve correctly distinguishes between versions when referencing fields that aren't '_id'. """ # put a second version - response, status = self.put(self.item_id_url, data=self.item_change, - headers=[('If-Match', self.item_etag)]) + response, status = self.put( + self.item_id_url, + data=self.item_change, + headers=[("If-Match", self.item_etag)], + ) self.assertGoodPutPatch(response, status) # try saving a field from the first version against version 2 - data = {"person": {'ref': self.item['ref'], self.version_field: 2}} - r, status = self.post('/invoices/', data=data) + data = {"person": {"ref": self.item["ref"], self.version_field: 2}} + r, status = self.post("/invoices/", data=data) self.assertValidationErrorStatus(status) self.assertValidationError( - r, {'person': "value '%s' must exist in " - "resource '%s', field '%s' at version '%s'." % - (self.item['ref'], 'contacts', 'ref', 2)}) + r, + { + "person": "value '%s' must exist in " + "resource '%s', field '%s' at version '%s'." + % (self.item["ref"], "contacts", "ref", 2) + }, + ) # try saving against the first version...this should work - data = {"person": {'ref': self.item['ref'], self.version_field: 1}} - r, status = self.post('/invoices/', data=data) + data = {"person": {"ref": self.item["ref"], self.version_field: 1}} + r, status = self.post("/invoices/", data=data) self.assert201(status) # and response should include embedded v1 response, status = self.get( - self.domain['invoices']['url'], + self.domain["invoices"]["url"], item=r[self.id_field], - query='?embedded={"person": 1}') + query='?embedded={"person": 1}', + ) self.assert200(status) - self.assertEqual(response['person'].get(self.version_field), 1) + self.assertEqual(response["person"].get(self.version_field), 1) class TestVersionedDataRelationUnversionedField(TestNormalVersioning): def setUp(self): - super(TestVersionedDataRelationUnversionedField, self).setUp() + super().setUp() # enable versioning in the invoice data_relation definition with custom # unversioned relation field self.enableDataVersionRelation( - custom_field=self.unversioned_field, custom_field_type='integer') + custom_field=self.unversioned_field, custom_field_type="integer" + ) self.enableVersioning(partial=True) self.insertTestData() def test_referential_integrity(self): - """ Make sure that Eve correctly distinguishes between versions when + """Make sure that Eve correctly distinguishes between versions when referencing unversioned fields """ # put a second version - response, status = self.put(self.item_id_url, data=self.item_change, - headers=[('If-Match', self.item_etag)]) + response, status = self.put( + self.item_id_url, + data=self.item_change, + headers=[("If-Match", self.item_etag)], + ) self.assertGoodPutPatch(response, status) # reference first version relation_field = self.unversioned_field - data = {"person": { - relation_field: self.item_change[relation_field], - self.version_field: 1 - }} - r, status = self.post('/invoices/', data=data) + data = { + "person": { + relation_field: self.item_change[relation_field], + self.version_field: 1, + } + } + r, status = self.post("/invoices/", data=data) self.assert201(status) # and response should include embedded v1 response, status = self.get( - self.domain['invoices']['url'], + self.domain["invoices"]["url"], item=r[self.id_field], - query='?embedded={"person": 1}') + query='?embedded={"person": 1}', + ) self.assert200(status) - self.assertEqual(response['person'].get(self.version_field), 1) + self.assertEqual(response["person"].get(self.version_field), 1) class TestPartialVersioning(TestNormalVersioning): def setUp(self): - super(TestPartialVersioning, self).setUp() + super().setUp() self.enableVersioning(partial=True) self.insertTestData() def test_get(self): - """ Test that get response successfully synthesize the full document + """Test that get response successfully synthesize the full document even with unversioned fields. """ self.do_test_get() def test_getitem(self): - """ Test that get response can successfully synthesize both old and new + """Test that get response can successfully synthesize both old and new document versions when partial versioning is in place. """ self.do_test_getitem(partial=True) def test_post(self): - """ Verify that partial version control can happen on POST. - """ + """Verify that partial version control can happen on POST.""" self.do_test_post(partial=True) def test_multi_post(self): - """ Eve literally throws single documents into an array before + """Eve literally throws single documents into an array before processing them in a POST, so I don't feel the need to specially test the versioning features here. Making a stub nontheless. """ self.do_test_multi_post() def test_put(self): - """ Verify that partial version control can happen on PUT. - """ + """Verify that partial version control can happen on PUT.""" self.do_test_put(partial=True) def test_patch(self): - """ Verify that partial version control can happen on PATCH. - """ + """Verify that partial version control can happen on PATCH.""" self.do_test_patch(partial=True) def test_version_control_the_unkown(self): - """ Currently, the versioning scheme assumes true unless a field is + """Currently, the versioning scheme assumes true unless a field is explicitly marked to not be version controlled. That means, if 'allow_unknown' is enabled, those fields are always version controlled. This is the same behavior as under TestCompleteVersioning. @@ -1171,7 +1300,7 @@ def test_version_control_the_unkown(self): class TestLateVersioning(TestVersioningBase): def setUp(self): - super(TestLateVersioning, self).setUp() + super().setUp() # enable versioning in the invoice data_relation definition self.enableDataVersionRelation(embeddable=True) @@ -1180,18 +1309,18 @@ def setUp(self): self.enableVersioning() def test_get(self): - """ Make sure that Eve returns version = 1 even for documents that + """Make sure that Eve returns version = 1 even for documents that haven't been modified since version control has been turned on. """ response, status = self.get(self.known_resource) self.assert200(status) - items = response[self.app.config['ITEMS']] - self.assertEqual(len(items), self.app.config['PAGINATION_DEFAULT']) + items = response[self.app.config["ITEMS"]] + self.assertEqual(len(items), self.app.config["PAGINATION_DEFAULT"]) for item in items: self.assertDocumentVersionFields(item, 1) def test_getitem(self): - """ Make sure that Eve returns version = 1 even for documents that + """Make sure that Eve returns version = 1 even for documents that haven't been modified since version control has been turned on. """ response, status = self.get(self.known_resource, item=self.item_id) @@ -1199,7 +1328,7 @@ def test_getitem(self): self.assertDocumentVersionFields(response, 1) def test_put(self): - """ Make sure that Eve jumps to version = 2 and saves two shadow copies + """Make sure that Eve jumps to version = 2 and saves two shadow copies (version 1 and version 2) for documents that where already in the database before version control was turned on. """ @@ -1208,8 +1337,9 @@ def test_put(self): # put a change changes = {"ref": "this is a different value"} - response, status = self.put(self.item_id_url, data=changes, - headers=[('If-Match', self.item_etag)]) + response, status = self.put( + self.item_id_url, data=changes, headers=[("If-Match", self.item_etag)] + ) self.assertGoodPutPatch(response, status) self.assertDocumentVersionFields(response, 2) @@ -1221,7 +1351,7 @@ def test_put(self): self.assertEqual(response[ETAG], response2[ETAG]) def test_patch(self): - """ Make sure that Eve jumps to version = 2 and saves two shadow copies + """Make sure that Eve jumps to version = 2 and saves two shadow copies (version 1 and version 2) for documents that where already in the database before version control was turned on. """ @@ -1231,8 +1361,8 @@ def test_patch(self): # patch a change changes = {"ref": "this is a different value"} response, status = self.patch( - self.item_id_url, data=changes, - headers=[('If-Match', self.item_etag)]) + self.item_id_url, data=changes, headers=[("If-Match", self.item_etag)] + ) self.assertGoodPutPatch(response, status) self.assertDocumentVersionFields(response, 2) @@ -1244,7 +1374,7 @@ def test_patch(self): self.assertEqual(response[ETAG], response2[ETAG]) def test_datasource(self): - """ Make sure that Eve uses the same mongo collection for storing versions + """Make sure that Eve uses the same mongo collection for storing versions when datasource is used.""" # make sure there are no shadow documents self.assertTrue(self.countShadowDocuments() == 0) @@ -1252,8 +1382,8 @@ def test_datasource(self): # patch a change changes = {"ref": "this is a different value"} response, status = self.patch( - self.item_id_url, data=changes, - headers=[('If-Match', self.item_etag)]) + self.item_id_url, data=changes, headers=[("If-Match", self.item_etag)] + ) self.assertGoodPutPatch(response, status) self.assertDocumentVersionFields(response, 2) @@ -1261,8 +1391,8 @@ def test_datasource(self): self.assertTrue(self.countShadowDocuments() == 2) data = { - self.versioned_field: 'ref value 3..............', - self.unversioned_field: 444 + self.versioned_field: "ref value 3..............", + self.unversioned_field: 444, } contact, status = self.post(self.known_resource_url, data=data) self.assert201(status) @@ -1271,11 +1401,11 @@ def test_datasource(self): self.assertEqual(self.countShadowDocuments(), 3) def test_delete(self): - """ Verify that we don't throw an error if we delete a resource that is + """Verify that we don't throw an error if we delete a resource that is supposed to be versioned but whose shadow collection does not exist. """ # turn off filter setting - self.domain['contacts']['datasource']['filter'] = None + self.domain["contacts"]["datasource"]["filter"] = None # verify the primary collection exists but the shadow does not self.assertTrue(self.countDocuments() > 0) @@ -1290,7 +1420,7 @@ def test_delete(self): self.assertTrue(self.countShadowDocuments() == 0) def test_deleteitem(self): - """ Verify that we don't throw an error if we delete an item that is + """Verify that we don't throw an error if we delete an item that is supposed to be versioned but that doesn't have any shadow copies. """ # verify the primary document exists but no shadow documents do @@ -1299,7 +1429,8 @@ def test_deleteitem(self): # delete resource and verify no errors response, status = self.delete( - self.item_id_url, headers=[('If-Match', self.item_etag)]) + self.item_id_url, headers=[("If-Match", self.item_etag)] + ) self.assert204(status) # verify that neither primary or shadow documents exist @@ -1307,7 +1438,7 @@ def test_deleteitem(self): self.assertTrue(self.countShadowDocuments(self.item_id) == 0) def test_softdelete(self): - """ Make sure that Eve jumps to version = 2 and saves two shadow copies + """Make sure that Eve jumps to version = 2 and saves two shadow copies (version 1 and version 2) for documents that where already in the database before version control was turned on. """ @@ -1319,7 +1450,8 @@ def test_softdelete(self): # soft delete resource and verify no errors response, status = self.delete( - self.item_id_url, headers=[('If-Match', self.item_etag)]) + self.item_id_url, headers=[("If-Match", self.item_etag)] + ) self.assert204(status) # verify that the primary document and two (late caught v1 and deleted @@ -1328,52 +1460,49 @@ def test_softdelete(self): self.assertTrue(self.countShadowDocuments(self.item_id) == 2) def test_referential_integrity(self): - """ Make sure that Eve doesn't mind doing a data relation even when the + """Make sure that Eve doesn't mind doing a data relation even when the shadow copy doesn't exist. """ - data_relation = \ - self.domain['invoices']['schema']['person']['data_relation'] - value_field = data_relation['field'] - version_field = self.app.config['VERSION'] + data_relation = self.domain["invoices"]["schema"]["person"]["data_relation"] + value_field = data_relation["field"] + version_field = self.app.config["VERSION"] # verify that Eve will take version = 1 if no shadow docs exist data = {"person": {value_field: self.item_id, version_field: 1}} - response, status = self.post('/invoices/', data=data) + response, status = self.post("/invoices/", data=data) self.assert201(status) def test_embedded(self): - """ Perform a quick check to make sure that Eve can embedded with a + """Perform a quick check to make sure that Eve can embedded with a version in the data relation. """ - data_relation = \ - self.domain['invoices']['schema']['person']['data_relation'] - value_field = data_relation['field'] - version_field = self.app.config['VERSION'] + data_relation = self.domain["invoices"]["schema"]["person"]["data_relation"] + value_field = data_relation["field"] + version_field = self.app.config["VERSION"] # verify that Eve will take version = 1 if no shadow docs exist data = {"person": {value_field: self.item_id, version_field: 1}} - response, status = self.post('/invoices/', data=data) + response, status = self.post("/invoices/", data=data) self.assert201(status) invoice_id = response[value_field] # verify that we can embed across the data_relation w/o shadow copy response, status = self.get( - self.domain['invoices']['url'], - item=invoice_id, query='?embedded={"person": 1}') + self.domain["invoices"]["url"], + item=invoice_id, + query='?embedded={"person": 1}', + ) self.assert200(status) - self.assertTrue('ref' in response['person']) + self.assertTrue("ref" in response["person"]) class TestVersioningWithCustomIdField(TestNormalVersioning): def setUp(self): - super(TestVersioningWithCustomIdField, self).setUp() - self.domain[self.known_resource]['schema'][self.id_field] = { - 'type': 'string', - } + super().setUp() + self.domain[self.known_resource]["schema"][self.id_field] = {"type": "string"} self.enableVersioning() self.insertTestData() def test_getitem(self): - """ Make sure we can insert at least two versioning documents. - """ + """Make sure we can insert at least two versioning documents.""" self.do_test_getitem(partial=False) diff --git a/tox.ini b/tox.ini index e21c1225c..c34787e03 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,17 @@ [tox] -envlist=py26,py27,py33,py34,py35,pypy,flake8 +envlist=py3{12,11,10,9},pypy3{10,9},linting [testenv] -commands=python setup.py test {posargs} +extras=tests +commands=pytest tests {posargs} -[testenv:flake8] -deps=flake8 -basepython=python2 -commands=flake8 --ignore=E731 eve {posargs} +[testenv:linting] +skipsdist = True +usedevelop = True +basepython = python3.9 +deps = pre-commit +commands = pre-commit run --all-files + +[flake8] +max-line-length = 88 +ignore = E401,E722,W503,F821,E501,E203