├── .github ├── CODEOWNERS ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ └── project-is-unmaintained.md ├── PULL_REQUEST_TEMPLATE.md ├── SECURITY.md ├── dependabot.yml ├── release.yml └── workflows │ ├── labels.yaml │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .gitmodules ├── CHANGES.txt ├── CONTRIBUTORS.txt ├── LICENSE ├── Makefile ├── README.rst ├── docs ├── Makefile ├── make.bat ├── requirements.txt └── source │ ├── api.rst │ ├── conf.py │ ├── cornice.png │ ├── exhaustive_list.rst │ ├── faq.rst │ ├── i18n.rst │ ├── index.rst │ ├── internals.rst │ ├── quickstart.rst │ ├── resources.rst │ ├── schema.rst │ ├── services.rst │ ├── testing.rst │ ├── tutorial.rst │ ├── upgrading.rst │ └── validation.rst ├── pyproject.toml ├── requirements.in ├── requirements.txt ├── src └── cornice │ ├── __init__.py │ ├── cors.py │ ├── errors.py │ ├── pyramidhook.py │ ├── renderer.py │ ├── resource.py │ ├── service.py │ ├── util.py │ └── validators │ ├── __init__.py │ ├── _colander.py │ └── _marshmallow.py └── tests ├── __init__.py ├── support.py ├── test_cors.py ├── test_errors.py ├── test_imperative_resource.py ├── test_init.py ├── test_pyramidhook.py ├── test_renderer.py ├── test_resource.py ├── test_resource_callable.py ├── test_resource_custom_predicates.py ├── test_resource_traverse.py ├── test_service.py ├── test_service_definition.py ├── test_util.py ├── test_validation.py └── validationapp.py /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @Cornices/Cornice 2 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | How to contribute 2 | ================= 3 | 4 | Thanks for your interest in contributing! 5 | 6 | ## Reporting Bugs 7 | 8 | Report bugs at https://github.com/Cornices/cornice/issues/new 9 | 10 | If you are reporting a bug, please include: 11 | 12 | - Any details about your local setup that might be helpful in troubleshooting. 13 | - Detailed steps to reproduce the bug or even a PR with a failing tests if you can. 14 | 15 | 16 | ## Ready to contribute? 17 | 18 | ### Getting Started 19 | 20 | - Fork the repo on GitHub and clone locally: 21 | 22 | ```bash 23 | git clone git@github.com:Cornices/cornice.git 24 | git remote add {your_name} git@github.com:{your_name}/cornice.git 25 | ``` 26 | 27 | ## Testing 28 | 29 | - `make test` to run all the tests 30 | 31 | ## Submitting Changes 32 | 33 | ```bash 34 | git checkout main 35 | git pull origin main 36 | git checkout -b issue_number-bug-title 37 | git commit # Your changes 38 | git push -u {your_name} issue_number-bug-title 39 | ``` 40 | 41 | Then you can create a Pull-Request. 42 | Please create your pull-request as soon as you have at least one commit even if it has only failing tests. This will allow us to help and give guidance. 43 | 44 | You will be able to update your pull-request by pushing commits to your branch. 45 | 46 | 47 | ## Releasing 48 | 49 | 1. Create a release on Github on https://github.com/Cornices/cornice/releases/new 50 | 2. Create a new tag `X.Y.Z` (*This tag will be created from the target when you publish this release.*) 51 | 3. Generate release notes 52 | 4. Publish release 53 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/project-is-unmaintained.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Project is unmaintained 3 | about: 'Warning: project is unmaintained' 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | > 🚀 **Maintainer Wanted!** This project is looking for a new steward to keep it alive! 🌱 If you still care for this project 🛠️, drop a comment or a PR! 🎉✨ 11 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | > 🚀 **Maintainer Wanted!** This project is looking for a new steward to keep it alive! 🌱 If you still care for this project 🛠️, drop a comment or a PR removing this warning! 🎉✨ 2 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 6.x.x | :white_check_mark: | 8 | | < 6.0 | :x: | 9 | 10 | ## Reporting a Vulnerability 11 | 12 | If you believe you have found a Cornice-related security vulnerability, please [report it in a security advisory](https://github.com/Cornices/cornice/security/advisories/new). 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 99 8 | groups: 9 | all-dependencies: 10 | update-types: ["major", "minor", "patch"] 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: weekly 15 | open-pull-requests-limit: 99 16 | groups: 17 | all-dependencies: 18 | update-types: ["major", "minor", "patch"] 19 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot 5 | categories: 6 | - title: Breaking Changes 7 | labels: 8 | - "breaking-change" 9 | - title: Bug Fixes 10 | labels: 11 | - "bug" 12 | - title: New Features 13 | labels: 14 | - "enhancement" 15 | - title: Documentation 16 | labels: 17 | - "documentation" 18 | - title: Dependency Updates 19 | labels: 20 | - "dependencies" 21 | - title: Other Changes 22 | labels: 23 | - "*" 24 | -------------------------------------------------------------------------------- /.github/workflows/labels.yaml: -------------------------------------------------------------------------------- 1 | name: Force pull-requests label(s) 2 | 3 | on: 4 | pull_request: 5 | types: [opened, labeled, unlabeled] 6 | jobs: 7 | pr-has-label: 8 | name: Will be skipped if labelled 9 | runs-on: ubuntu-latest 10 | if: ${{ join(github.event.pull_request.labels.*.name, ', ') == '' }} 11 | steps: 12 | - run: | 13 | echo 'Pull-request must have at least one label' 14 | exit 1 15 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distribution 📦 to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | name: Build distribution 📦 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.x" 20 | 21 | - name: Print environment 22 | run: | 23 | python --version 24 | 25 | - name: Install pypa/build 26 | run: python3 -m pip install build 27 | 28 | - name: Build a binary wheel and a source tarball 29 | run: python3 -m build 30 | 31 | - name: Store the distribution packages 32 | uses: actions/upload-artifact@v4 33 | with: 34 | name: python-package-distributions 35 | path: dist/ 36 | 37 | publish-to-pypi: 38 | name: Publish Python 🐍 distribution 📦 to PyPI 39 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 40 | needs: 41 | - build 42 | runs-on: ubuntu-latest 43 | environment: 44 | name: release 45 | url: https://pypi.org/p/cornice 46 | permissions: 47 | id-token: write 48 | steps: 49 | - name: Download all the dists 50 | uses: actions/download-artifact@v4 51 | with: 52 | name: python-package-distributions 53 | path: dist/ 54 | - name: Publish distribution 📦 to PyPI 55 | uses: pypa/gh-action-pypi-publish@release/v1 56 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: pull_request 2 | 3 | jobs: 4 | lint: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | 9 | - uses: actions/setup-python@v5 10 | 11 | - name: Run linting and formatting checks 12 | run: make lint 13 | 14 | unit-tests: 15 | name: Unit Tests 16 | needs: lint 17 | runs-on: ubuntu-latest 18 | strategy: 19 | matrix: 20 | python-version: ["3.9", "3.10", "3.11", "3.12"] 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | cache: pip 30 | 31 | - name: Install dependencies 32 | run: make install 33 | 34 | - name: Run unit tests 35 | run: make test 36 | 37 | docs: 38 | runs-on: ubuntu-latest 39 | needs: lint 40 | steps: 41 | - uses: actions/checkout@v4 42 | 43 | - uses: actions/setup-python@v5 44 | 45 | - name: Build docs 46 | run: make docs 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .tox 2 | .hg* 3 | *.pyc 4 | *.mako.py 5 | lib 6 | include 7 | bin 8 | deps 9 | *.egg* 10 | *.swp 11 | html 12 | docs/build 13 | .coverage 14 | *~ 15 | ._build.etag 16 | _build.py.bak* 17 | test 18 | man 19 | .channel 20 | dist/ 21 | .venv/ 22 | venv/ 23 | .idea/ 24 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "docs/source/_themes/mozilla"] 2 | path = docs/source/_themes/mozilla 3 | url = https://github.com/ametaireau/mozilla-sphinx-theme.git 4 | -------------------------------------------------------------------------------- /CONTRIBUTORS.txt: -------------------------------------------------------------------------------- 1 | Here is a (alphabetically-ordered) list of the people who contributed to 2 | Cornice: 3 | 4 | * Akim Sadaoui 5 | * Alessandro Amici 6 | * Alexandre Bourget 7 | * Alexis Metaireau 8 | * Alex Marandon 9 | * Anaël Beutot 10 | * Andilab 11 | * Andreas Motl 12 | * Andreas Zeidler 13 | * Andre Caron 14 | * Andrew Brookins 15 | * Antoine Leclair 16 | * Anton D 17 | * Areski 18 | * Ben Bangert 19 | * Andrew Birnberg 20 | * Bruno Binet 21 | * Bryan Zegar 22 | * Christian Benke 23 | * Christian Thieme 24 | * Damian Dimmich 25 | * Daniel M. Weeks 26 | * David Charboneau 27 | * David Grant 28 | * Dion Peters 29 | * Elias 30 | * Gabriela Surita 31 | * Gael Pasgrimaud 32 | * George V. Reilly 33 | * George Mamalakis 34 | * Graham Higgins 35 | * G.Tjebbes 36 | * Guillaume Gauvrit 37 | * Henddher Pedroza 38 | * James Arthur 39 | * James Saxon 40 | * Janek Hiis 41 | * Jeff Dairiki 42 | * Jeff Marshall 43 | * Jens Carl 44 | * Joe Steeve 45 | * John Brodie 46 | * Jon Staley 47 | * Joshua Immanuel 48 | * Josip Delic 49 | * Jürgen Gmach 50 | * Karthikeyan Singaravelan 51 | * Kit Randel 52 | * Laurence Rowe 53 | * Lorenzo Gil Sanchez 54 | * Lucas Taylor 55 | * Luke Macken 56 | * Luper Rouch 57 | * Marc Abramowitz 58 | * Marcin Lulek 59 | * Marconi Moreto 60 | * Markus Zapke-Gründemann 61 | * Mathieu Bridon 62 | * Mathieu Leplatre 63 | * Matthias Lehmann 64 | * Matthias 65 | * Matt Skinner 66 | * Myroslav Opyr 67 | * Nicolas Dietrich 68 | * Niko Wenselowski 69 | * Olivier Roussy 70 | * Paul Smith 71 | * Ralph Bean 72 | * Ramana Rao 73 | * Rémy HUBSCHER 74 | * rforkel 75 | * Rickert Mulder 76 | * Rob Miller 77 | * Rob van der Linde 78 | * Roman Kozlovskyi 79 | * Ryan Kelly 80 | * Scott Bowers 81 | * Sebastian Hanula 82 | * Sergey Safonov 83 | * sghill 84 | * Simone Marzola 85 | * Tarek Ziade 86 | * Tim Tisdall 87 | * Tom Lazar 88 | * Tomasz Czekański 89 | * Tres Seaver 90 | * tsauerwein 91 | * Victor Ng 92 | * Vincent Fretin 93 | * Ymage 94 | * Volodymyr Maksymiv 95 | * Walter Danilo Galante 96 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This Source Code Form is subject to the terms of the Mozilla Public License, v. 2 | 2.0. If a copy of the MPL was not distributed with this file, You can obtain 3 | one at http://mozilla.org/MPL/2.0/. 4 | 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VENV := $(shell echo $${VIRTUAL_ENV-.venv}) 2 | PYTHON = $(VENV)/bin/python 3 | SPHINX_BUILD = $(shell realpath ${VENV})/bin/sphinx-build 4 | INSTALL_STAMP = $(VENV)/.install.stamp 5 | 6 | .PHONY: all 7 | all: install 8 | 9 | install: $(INSTALL_STAMP) 10 | $(INSTALL_STAMP): $(PYTHON) pyproject.toml requirements.txt 11 | $(VENV)/bin/pip install -U pip 12 | $(VENV)/bin/pip install -r requirements.txt 13 | $(VENV)/bin/pip install -r docs/requirements.txt 14 | $(VENV)/bin/pip install -e ".[dev]" 15 | touch $(INSTALL_STAMP) 16 | 17 | $(PYTHON): 18 | python3 -m venv $(VENV) 19 | 20 | requirements.txt: requirements.in 21 | pip-compile 22 | 23 | .PHONY: test 24 | test: install 25 | $(VENV)/bin/pytest --cov-report term-missing --cov-fail-under 100 --cov cornice 26 | 27 | .PHONY: lint 28 | lint: install 29 | $(VENV)/bin/ruff check src tests 30 | $(VENV)/bin/ruff format --check src tests 31 | 32 | .PHONY: format 33 | format: install 34 | $(VENV)/bin/ruff check --fix src tests 35 | $(VENV)/bin/ruff format src tests 36 | 37 | docs: install 38 | cd docs && $(MAKE) html SPHINXBUILD=$(SPHINX_BUILD) 39 | 40 | .IGNORE: clean 41 | clean: 42 | find src -name '__pycache__' -type d -exec rm -fr {} \; 43 | find tests -name '__pycache__' -type d -exec rm -fr {} \; 44 | rm -rf .venv .coverage *.egg-info .pytest_cache .ruff_cache build dist 45 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Cornice 3 | ======= 4 | 5 | .. warning:: 6 | 7 | 🚀 **Maintainer Wanted!** This project is looking for a new steward to keep it alive! 🌱 If you still care for this project 🛠️, drop a comment or a PR! 🎉✨ 8 | 9 | |readthedocs| |pypi| |github-actions| |main-coverage| 10 | 11 | .. |github-actions| image:: https://github.com/Cornices/cornice/workflows/Unit%20Testing/badge.svg 12 | :target: https://github.com/Cornices/cornice/actions?query=workflow%3A%22Unit+Testing%22 13 | 14 | .. |readthedocs| image:: https://readthedocs.org/projects/cornice/badge/?version=latest 15 | :target: https://cornice.readthedocs.io/en/latest/ 16 | :alt: Documentation Status 17 | 18 | .. |main-coverage| image:: 19 | https://coveralls.io/repos/Cornices/cornice/badge.svg?branch=main 20 | :alt: Coverage 21 | :target: https://coveralls.io/r/Cornices/cornice 22 | 23 | .. |pypi| image:: https://img.shields.io/pypi/v/cornice.svg 24 | :target: https://pypi.python.org/pypi/cornice 25 | 26 | 27 | **Cornice** provides helpers to build & document Web Services with Pyramid. 28 | 29 | The full documentation is available at: https://cornice.readthedocs.io 30 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 14 | 15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " singlehtml to make a single large HTML file" 22 | @echo " pickle to make pickle files" 23 | @echo " json to make JSON files" 24 | @echo " htmlhelp to make HTML files and a HTML help project" 25 | @echo " qthelp to make HTML files and a qthelp project" 26 | @echo " devhelp to make HTML files and a Devhelp project" 27 | @echo " epub to make an epub" 28 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 29 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 30 | @echo " text to make text files" 31 | @echo " man to make manual pages" 32 | @echo " changes to make an overview of all changed/added/deprecated items" 33 | @echo " linkcheck to check all external links for integrity" 34 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 35 | 36 | clean: 37 | -rm -rf $(BUILDDIR)/* 38 | 39 | html: 40 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 41 | @echo 42 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 43 | 44 | dirhtml: 45 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 48 | 49 | singlehtml: 50 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 51 | @echo 52 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 53 | 54 | pickle: 55 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 56 | @echo 57 | @echo "Build finished; now you can process the pickle files." 58 | 59 | json: 60 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 61 | @echo 62 | @echo "Build finished; now you can process the JSON files." 63 | 64 | htmlhelp: 65 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 66 | @echo 67 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 68 | ".hhp project file in $(BUILDDIR)/htmlhelp." 69 | 70 | qthelp: 71 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 72 | @echo 73 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 74 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 75 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Cornice.qhcp" 76 | @echo "To view the help file:" 77 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Cornice.qhc" 78 | 79 | devhelp: 80 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 81 | @echo 82 | @echo "Build finished." 83 | @echo "To view the help file:" 84 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Cornice" 85 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Cornice" 86 | @echo "# devhelp" 87 | 88 | epub: 89 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 90 | @echo 91 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 92 | 93 | latex: 94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 95 | @echo 96 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 97 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 98 | "(use \`make latexpdf' here to do that automatically)." 99 | 100 | latexpdf: 101 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 102 | @echo "Running LaTeX files through pdflatex..." 103 | make -C $(BUILDDIR)/latex all-pdf 104 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 105 | 106 | text: 107 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 108 | @echo 109 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 110 | 111 | man: 112 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 113 | @echo 114 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 115 | 116 | changes: 117 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 118 | @echo 119 | @echo "The overview file is in $(BUILDDIR)/changes." 120 | 121 | linkcheck: 122 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 123 | @echo 124 | @echo "Link check complete; look for any errors in the above output " \ 125 | "or in $(BUILDDIR)/linkcheck/output.txt." 126 | 127 | doctest: 128 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 129 | @echo "Testing of doctests in the sources finished, look at the " \ 130 | "results in $(BUILDDIR)/doctest/output.txt." 131 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source 10 | if NOT "%PAPER%" == "" ( 11 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 12 | ) 13 | 14 | if "%1" == "" goto help 15 | 16 | if "%1" == "help" ( 17 | :help 18 | echo.Please use `make ^` where ^ is one of 19 | echo. html to make standalone HTML files 20 | echo. dirhtml to make HTML files named index.html in directories 21 | echo. singlehtml to make a single large HTML file 22 | echo. pickle to make pickle files 23 | echo. json to make JSON files 24 | echo. htmlhelp to make HTML files and a HTML help project 25 | echo. qthelp to make HTML files and a qthelp project 26 | echo. devhelp to make HTML files and a Devhelp project 27 | echo. epub to make an epub 28 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 29 | echo. text to make text files 30 | echo. man to make manual pages 31 | echo. changes to make an overview over all changed/added/deprecated items 32 | echo. linkcheck to check all external links for integrity 33 | echo. doctest to run all doctests embedded in the documentation if enabled 34 | goto end 35 | ) 36 | 37 | if "%1" == "clean" ( 38 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 39 | del /q /s %BUILDDIR%\* 40 | goto end 41 | ) 42 | 43 | if "%1" == "html" ( 44 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 45 | if errorlevel 1 exit /b 1 46 | echo. 47 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 48 | goto end 49 | ) 50 | 51 | if "%1" == "dirhtml" ( 52 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 53 | if errorlevel 1 exit /b 1 54 | echo. 55 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 56 | goto end 57 | ) 58 | 59 | if "%1" == "singlehtml" ( 60 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 61 | if errorlevel 1 exit /b 1 62 | echo. 63 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 64 | goto end 65 | ) 66 | 67 | if "%1" == "pickle" ( 68 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 69 | if errorlevel 1 exit /b 1 70 | echo. 71 | echo.Build finished; now you can process the pickle files. 72 | goto end 73 | ) 74 | 75 | if "%1" == "json" ( 76 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished; now you can process the JSON files. 80 | goto end 81 | ) 82 | 83 | if "%1" == "htmlhelp" ( 84 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished; now you can run HTML Help Workshop with the ^ 88 | .hhp project file in %BUILDDIR%/htmlhelp. 89 | goto end 90 | ) 91 | 92 | if "%1" == "qthelp" ( 93 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 94 | if errorlevel 1 exit /b 1 95 | echo. 96 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 97 | .qhcp project file in %BUILDDIR%/qthelp, like this: 98 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Cornice.qhcp 99 | echo.To view the help file: 100 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Cornice.ghc 101 | goto end 102 | ) 103 | 104 | if "%1" == "devhelp" ( 105 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 106 | if errorlevel 1 exit /b 1 107 | echo. 108 | echo.Build finished. 109 | goto end 110 | ) 111 | 112 | if "%1" == "epub" ( 113 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 117 | goto end 118 | ) 119 | 120 | if "%1" == "latex" ( 121 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 122 | if errorlevel 1 exit /b 1 123 | echo. 124 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 125 | goto end 126 | ) 127 | 128 | if "%1" == "text" ( 129 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 130 | if errorlevel 1 exit /b 1 131 | echo. 132 | echo.Build finished. The text files are in %BUILDDIR%/text. 133 | goto end 134 | ) 135 | 136 | if "%1" == "man" ( 137 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 141 | goto end 142 | ) 143 | 144 | if "%1" == "changes" ( 145 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.The overview file is in %BUILDDIR%/changes. 149 | goto end 150 | ) 151 | 152 | if "%1" == "linkcheck" ( 153 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Link check complete; look for any errors in the above output ^ 157 | or in %BUILDDIR%/linkcheck/output.txt. 158 | goto end 159 | ) 160 | 161 | if "%1" == "doctest" ( 162 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 163 | if errorlevel 1 exit /b 1 164 | echo. 165 | echo.Testing of doctests in the sources finished, look at the ^ 166 | results in %BUILDDIR%/doctest/output.txt. 167 | goto end 168 | ) 169 | 170 | :end 171 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mozilla-sphinx-theme==0.2 2 | cornice_sphinx 3 | colander 4 | Sphinx 5 | -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | Cornice API 2 | ########### 3 | 4 | Service 5 | ======= 6 | 7 | .. py:module:: cornice.service 8 | 9 | This document describes the methods proposed by cornice. It is 10 | automatically generated from the source code. 11 | 12 | .. autoclass:: cornice.service.Service 13 | .. autofunction:: cornice.service.decorate_view 14 | 15 | 16 | Resource 17 | ======== 18 | 19 | .. autofunction:: cornice.resource.resource 20 | .. autofunction:: cornice.resource.view 21 | .. autofunction:: cornice.resource.add_view 22 | .. autofunction:: cornice.resource.add_resource 23 | 24 | 25 | Validation 26 | ========== 27 | 28 | .. autofunction:: cornice.validators.extract_cstruct 29 | .. autofunction:: cornice.validators.colander_body_validator 30 | .. autofunction:: cornice.validators.colander_headers_validator 31 | .. autofunction:: cornice.validators.colander_path_validator 32 | .. autofunction:: cornice.validators.colander_querystring_validator 33 | .. autofunction:: cornice.validators.colander_validator 34 | .. autofunction:: cornice.validators.marshmallow_body_validator 35 | .. autofunction:: cornice.validators.marshmallow_headers_validator 36 | .. autofunction:: cornice.validators.marshmallow_path_validator 37 | .. autofunction:: cornice.validators.marshmallow_querystring_validator 38 | .. autofunction:: cornice.validators.marshmallow_validator 39 | 40 | Errors 41 | ====== 42 | 43 | .. autoclass:: cornice.errors.Errors 44 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | # -*- coding: utf-8 -*- 3 | import datetime 4 | import os 5 | import sys 6 | import pkg_resources 7 | try: 8 | import mozilla_sphinx_theme 9 | except ImportError: 10 | print('please install the \'mozilla-sphinx-theme\' distribution') 11 | 12 | sys.path.insert(0, os.path.abspath('../..')) # include cornice from the source 13 | extensions = ['sphinx.ext.autodoc'] 14 | 15 | templates_path = ['_templates'] 16 | source_suffix = '.rst' 17 | master_doc = 'index' 18 | project = u'Cornice' 19 | this_year = datetime.datetime.now().year 20 | copyright = u'2011-{}, Mozilla Services'.format(this_year) 21 | 22 | # The version info for the project you're documenting, acts as replacement for 23 | # |version| and |release|, also used in various other places throughout the 24 | # built documents. 25 | # 26 | # The short X.Y version. 27 | version = pkg_resources.get_distribution('cornice').version 28 | # The full version, including alpha/beta/rc tags. 29 | release = version 30 | 31 | exclude_patterns = [] 32 | 33 | html_theme_path = [os.path.dirname(mozilla_sphinx_theme.__file__)] 34 | 35 | html_theme = 'mozilla' 36 | html_static_path = ['_static'] 37 | htmlhelp_basename = 'Cornicedoc' 38 | 39 | latex_documents = [ 40 | ('index', 'Cornice.tex', u'Cornice Documentation', 41 | u'Mozilla Services', 'manual'), 42 | ] 43 | 44 | man_pages = [ 45 | ('index', 'cornice', u'Cornice Documentation', 46 | [u'Mozilla Services'], 1) 47 | ] 48 | -------------------------------------------------------------------------------- /docs/source/cornice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cornices/cornice/1d398c78299b9f66da55e2233d873a9481d92478/docs/source/cornice.png -------------------------------------------------------------------------------- /docs/source/exhaustive_list.rst: -------------------------------------------------------------------------------- 1 | Exhaustive features list 2 | ######################## 3 | 4 | As you may have noticed, Cornice does some validation for you. This document 5 | aims at documenting all those behaviours so you are not surprised if Cornice 6 | does it for you without noticing. 7 | 8 | Validation 9 | ========== 10 | 11 | Errors 12 | ~~~~~~ 13 | 14 | When validating contents, Cornice will automatically throw a 400 error if the 15 | data is invalid. Along with the 400 error, the body will contain a JSON dict 16 | which can be parsed to know more about the problems encountered. 17 | 18 | Method not allowed 19 | ~~~~~~~~~~~~~~~~~~ 20 | 21 | In cornice, one path equals one service. If you call a path with the wrong 22 | method, a `405 Method Not Allowed` error will be thrown (and not a 404), like 23 | specified in the HTTP specification. 24 | 25 | Authorization 26 | ~~~~~~~~~~~~~ 27 | 28 | Authorization can be done using pyramid's ACLs. If the authentication or 29 | the authorization fails at this stage, a 401 or 403 error is returned, 30 | depending on the cases. 31 | 32 | Content negotiation 33 | ~~~~~~~~~~~~~~~~~~~ 34 | 35 | This relates to **response body** internet media types aka. egress content types. 36 | 37 | Each method can specify a list of internet media types it can **respond** with. 38 | Per default, `text/html` is assumed. In the case the client requests an 39 | invalid media type via `Accept` header, cornice will return a 40 | `406 Not Acceptable` with an error message containing the list of available 41 | response content types for the particular URI and method. 42 | 43 | Request media type 44 | ~~~~~~~~~~~~~~~~~~ 45 | 46 | This relates to **request body** internet media types aka. ingress content types. 47 | 48 | Each method can specify a list of internet media types it accepts as **request** 49 | body format. Per default, any media type is allowed. In the case the client 50 | sends a request with an invalid `Content-Type` header, cornice will return a 51 | `415 Unsupported Media Type` with an error message containing the list of available 52 | request content types for the particular URI and method. 53 | 54 | Warning when returning JSON lists 55 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 56 | 57 | JSON lists are subject to security threats, as defined 58 | `in this document `_. 59 | In case you return a javascript list, a warning will be thrown. It will not 60 | however prevent you from returning the array. 61 | 62 | This behaviour can be disabled if needed (it can be removed from the list of 63 | default filters) 64 | 65 | 66 | URL prefix 67 | ========== 68 | 69 | It is possible to set a prefix for all your routes. For instance, if you want to 70 | prefix all your URIs by ``/v1/``. 71 | 72 | .. code-block:: python 73 | 74 | config.route_prefix = 'v2' 75 | config.include("cornice") 76 | 77 | 78 | CORS 79 | ==== 80 | 81 | Cornice can add CORS (Cross Origin Resource Sharing) support to your services. 82 | When enabled, it will define the appropriate views (``OPTIONS`` methods) 83 | and validators (headers etc.). 84 | 85 | See :ref:`more details...` 86 | -------------------------------------------------------------------------------- /docs/source/faq.rst: -------------------------------------------------------------------------------- 1 | Frequently Asked Questions (FAQ) 2 | ################################ 3 | 4 | Here is a list of frequently asked questions related to Cornice. 5 | 6 | Cornice registers exception handlers, how do I deal with it? 7 | ============================================================ 8 | 9 | Cornice registers its own exception handlers so it's able to behave the right 10 | way in some edge cases (it's mostly done for CORS support). 11 | 12 | Sometimes, you will need to register your own exception handlers, and Cornice 13 | might get on your way. 14 | 15 | You can disable the exception handling by using the `handle_exceptions` 16 | setting in your configuration file or in your main app: 17 | 18 | .. code-block:: python 19 | 20 | config.add_settings(handle_exceptions=False) 21 | -------------------------------------------------------------------------------- /docs/source/i18n.rst: -------------------------------------------------------------------------------- 1 | Internationalization and localization 2 | ##################################### 3 | 4 | For internationalization and localization to work with your project you should 5 | follow instructions on Pyramid docs https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/i18n.html. 6 | 7 | You should also add two settings to you project .ini file: 8 | 9 | 10 | .. code-block:: python 11 | 12 | pyramid.default_locale_name = en 13 | available_languages = en fr pl 14 | 15 | 16 | There are few places where translations will be performed automatically for you 17 | by Cornice using `request.localizer.translate `_ 18 | function: 19 | 20 | - Colander validation errors (standard validators and custom) 21 | - Custom validation errors added with `request.errors.add(location, name, description, **kwars)` 22 | (only `description` field will be translated) 23 | 24 | For custom error messages you are strongly advised to use 25 | `TranslationString `_ 26 | from `pyramid.i18n` module. 27 | 28 | 29 | .. code-block:: python 30 | 31 | from pyramid.i18n import TranslationString 32 | ts = TranslationString('My error message') 33 | 34 | 35 | This applies to errors returned from Cornice validation: 36 | 37 | 38 | .. code-block:: python 39 | 40 | ... 41 | 42 | from pyramid.i18n import TranslationString 43 | 44 | def custom_cornice_validator(request, **kwargs): 45 | # do some validation and add an error 46 | request.errors.add('body', 'field', TranslationString('My error message')) 47 | 48 | @service.get(validators=custom_cornice_validator) 49 | def service_handler(request): 50 | return {} 51 | 52 | 53 | and Colander validation too: 54 | 55 | 56 | .. code-block:: python 57 | 58 | ... 59 | 60 | from pyramid.i18n import TranslationString 61 | 62 | 63 | def custom_colander_validator(node, value): 64 | # do some validation 65 | request.errors.add('body', 'field', TranslationString('My error message')) 66 | 67 | def MySchema(MappingSchema): 68 | field = SchemaNode(String(), validator=custom_colander_validator) 69 | 70 | @service.get(schema=MySchema(), validators=colander_body_validator) 71 | def service_handler(request): 72 | return {} 73 | 74 | 75 | You can also use factory for `TranslationString` as it makes your code easier 76 | to read. 77 | 78 | 79 | .. seealso:: 80 | 81 | https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/i18n.html#using-the-translationstringfactory-class 82 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Cornice: A REST framework for Pyramid 2 | ##################################### 3 | 4 | **Cornice** provides helpers to build & document REST-ish Web Services 5 | with Pyramid, with decent default behaviors. It takes care of following the 6 | HTTP specification in an automated way where possible. 7 | 8 | We designed and implemented cornice in a really simple way, so 9 | it is easy to use and you can get started in a matter of minutes. 10 | 11 | Show me some code! 12 | ================== 13 | 14 | A **full** Cornice WSGI application looks like this (this example is taken from 15 | the `demoapp project `_):: 16 | 17 | from collections import defaultdict 18 | 19 | from pyramid.httpexceptions import HTTPForbidden 20 | from pyramid.view import view_config 21 | 22 | from cornice import Service 23 | 24 | 25 | user_info = Service(name='users', 26 | path='/{username}/info', 27 | description='Get and set user data.') 28 | 29 | _USERS = defaultdict(dict) 30 | 31 | 32 | @user_info.get() 33 | def get_info(request): 34 | """Returns the public information about a **user**. 35 | 36 | If the user does not exists, returns an empty dataset. 37 | """ 38 | username = request.matchdict['username'] 39 | return _USERS[username] 40 | 41 | 42 | @user_info.post() 43 | def set_info(request): 44 | """Set the public information for a **user**. 45 | 46 | You have to be that user, and *authenticated*. 47 | 48 | Returns *True* or *False*. 49 | """ 50 | username = request.authenticated_userid 51 | if request.matchdict["username"] != username: 52 | raise HTTPForbidden() 53 | _USERS[username] = request.json_body 54 | return {'success': True} 55 | 56 | 57 | @view_config(route_name="whoami", permission="authenticated", renderer="json") 58 | def whoami(request): 59 | """View returning the authenticated user's credentials.""" 60 | username = request.authenticated_userid 61 | principals = request.effective_principals 62 | return {"username": username, "principals": principals} 63 | 64 | What Cornice will do for you here is: 65 | 66 | - automatically raise a 405 if a DELETE or a PUT is called on 67 | **/{username}/info** 68 | - provide a validation framework that will return a nice JSON structure 69 | in Bad Request 400 responses explaining what's wrong. 70 | - provide an acceptable **Content-Type** whenever you send an HTTP "Accept" 71 | header to it, resulting in a *406 Not Acceptable* with the list of acceptable ones 72 | if it can't answer. 73 | 74 | Please follow up with :doc:`exhaustive_list` to get the picture. 75 | 76 | 77 | Documentation content 78 | ===================== 79 | 80 | .. toctree:: 81 | :maxdepth: 2 82 | 83 | quickstart 84 | tutorial 85 | services 86 | resources 87 | validation 88 | schema 89 | testing 90 | exhaustive_list 91 | api 92 | i18n 93 | internals 94 | faq 95 | upgrading 96 | 97 | 98 | Contribution & Feedback 99 | ======================= 100 | 101 | Cornice is a project initiated at Mozilla Services, where we build Web 102 | Services for features like Firefox Sync. All of what we do is built with open 103 | source, and this is one brick of our stack. 104 | 105 | We welcome Contributors and Feedback! 106 | 107 | - Developers Mailing List: https://mail.mozilla.org/listinfo/services-dev 108 | - Repository: https://github.com/Cornices/cornice 109 | -------------------------------------------------------------------------------- /docs/source/internals.rst: -------------------------------------------------------------------------------- 1 | Cornice internals 2 | ################# 3 | 4 | Internally, Cornice doesn't do a lot of magic. The logic is mainly split in two 5 | different locations: the `services.py` module and the `pyramid_hook.py` module. 6 | 7 | That's important to understand what they are doing in order to add new features 8 | or tweak the existing ones. 9 | 10 | The Service class 11 | ================= 12 | 13 | The :class:`cornice.service.Service` class is a container for all the definition 14 | information for a particular service. That's what you use when you use the 15 | Cornice decorators for instance, by doing things like 16 | ``@myservice.get(**kwargs)``. Under the hood, all the information you're passing 17 | to the service is stored in this class. Into other things you will find there: 18 | 19 | - the `name` of the registered service. 20 | - the `path` the service is available at. 21 | - the `description` of the service, if any. 22 | - the `defined_methods` for the current service. This is a list of strings. It 23 | shouldn't contain more than one time the same item. 24 | 25 | That's for the basic things. The last interesting part is what we call the 26 | "definitions". When you add a view to the service with the `add_view` method, 27 | it populates the definitions list, like this: 28 | 29 | .. code-block:: python 30 | 31 | self.definitions.append((method, view, args)) 32 | 33 | where `method` is the HTTP verb, `view` is the python callable and `args` are 34 | the arguments that are registered with this definition. It doesn't look this 35 | important, but this last argument is actually the most important one. It is a 36 | python dict containing the filters, validators, content types etc. 37 | 38 | There is one thing I didn't talk about yet: how we are getting the arguments 39 | from the service class. There is a handy `get_arguments` method, which returns 40 | the arguments from another list of given arguments. The goal is to fallback on 41 | instance-level arguments or class-level arguments if no arguments are provided 42 | at the add_view level. For instance, let's say I have a default service which 43 | renders to XML. I set its renderer in the class to "XML". 44 | 45 | When I register the information with :meth:`cornice.service.Service.add_view()`, 46 | ``renderer='XML'`` will be added automatically in the kwargs dict. 47 | 48 | Registering the definitions into the Pyramid routing system 49 | =========================================================== 50 | 51 | Okay, so once you added the services definition using the Service class, you 52 | might need to actually register the right routes into pyramid. The 53 | :mod:`cornice.pyramidhook` module takes care of this for you. 54 | 55 | What it does is that it checks all the services registered and call some 56 | functions of the pyramid framework on your behalf. 57 | 58 | What's interesting here is that this mechanism is not really tied to pyramid. 59 | for instance, we are doing the same thing `in cornice_sphinx `_ 60 | to generate the documentation: use the APIs that are exposed in the Service class 61 | and do something from it. 62 | 63 | To keep close to the flexibility of Pyramid's routing system, a ``traverse`` 64 | argument can be provided on service creation. It will be passed to the route 65 | declaration. This way you can combine URL Dispatch and traversal to build an 66 | hybrid application. 67 | -------------------------------------------------------------------------------- /docs/source/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. _quickstart: 2 | 3 | QuickStart for people in a hurry 4 | ================================ 5 | 6 | You are in a hurry, so we'll assume you are familiar with Pip ;) 7 | 8 | To use Cornice, install it:: 9 | 10 | $ pip install cornice 11 | 12 | You'll also need **waitress** (see https://pypi.python.org/pypi/waitress):: 13 | 14 | $ pip install waitress 15 | 16 | To start from scratch, you can use a `Cookiecutter `_ project template:: 17 | 18 | $ pip install cookiecutter 19 | $ cookiecutter gh:Cornices/cookiecutter-cornice 20 | ... 21 | 22 | Once your application is generated, go there and call *develop* against it:: 23 | 24 | $ cd myapp 25 | $ python setup.py develop 26 | ... 27 | 28 | The template creates a working Cornice application. 29 | 30 | .. note:: 31 | 32 | If you're familiar with Pyramid and just want to add *cornice* to an already 33 | existing project, you'll just need to include ``cornice`` in your project:: 34 | 35 | config.include("cornice") 36 | 37 | You can then start poking at the :file:`views.py` file. 38 | 39 | For example, let's define a service where you can **GET** and **POST** a value 40 | at **/values/{value}**, where *value* is an ascii value representing the 41 | name of the value. 42 | 43 | The :file:`views` module can look like this:: 44 | 45 | from cornice import Service 46 | 47 | _VALUES = {} 48 | 49 | values = Service(name='foo', 50 | path='/values/{value}', 51 | description="Cornice Demo") 52 | 53 | @values.get() 54 | def get_value(request): 55 | """Returns the value. 56 | """ 57 | key = request.matchdict['value'] 58 | return _VALUES.get(key) 59 | 60 | 61 | @values.post() 62 | def set_value(request): 63 | """Set the value. 64 | 65 | Returns *True* or *False*. 66 | """ 67 | key = request.matchdict['value'] 68 | try: 69 | # json_body is JSON-decoded variant of the request body 70 | _VALUES[key] = request.json_body 71 | except ValueError: 72 | return False 73 | return True 74 | 75 | 76 | .. note:: 77 | 78 | By default, Cornice uses a Json renderer. 79 | 80 | 81 | Run your Cornice application with:: 82 | 83 | $ pserve project.ini --reload 84 | 85 | 86 | Set a key-value using Curl:: 87 | 88 | $ curl -X POST http://localhost:6543/values/foo -d '{"a": 1}' 89 | 90 | 91 | Check out what is stored in a ``foo`` value at http://localhost:6543/values/foo 92 | -------------------------------------------------------------------------------- /docs/source/resources.rst: -------------------------------------------------------------------------------- 1 | Defining resources 2 | ################## 3 | 4 | Cornice is also able to handle REST "resources" for you. You can declare 5 | a class with some put, post, get etc. methods (the HTTP verbs) and they will be 6 | registered as handlers for the appropriate methods / services. 7 | 8 | Here is how you can register a resource: 9 | 10 | .. code-block:: python 11 | 12 | from cornice.resource import resource 13 | 14 | _USERS = {1: {'name': 'gawel'}, 2: {'name': 'tarek'}} 15 | 16 | @resource(collection_path='/users', path='/users/{id}') 17 | class User(object): 18 | 19 | def __init__(self, request, context=None): 20 | self.request = request 21 | 22 | def __acl__(self): 23 | return [(Allow, Everyone, 'everything')] 24 | 25 | def collection_get(self): 26 | return {'users': _USERS.keys()} 27 | 28 | def get(self): 29 | return _USERS.get(int(self.request.matchdict['id'])) 30 | 31 | def collection_post(self): 32 | print(self.request.json_body) 33 | _USERS[len(_USERS) + 1] = self.request.json_body 34 | return True 35 | 36 | Imperatively 37 | ============ 38 | 39 | Here is an example of how to define cornice resources in an imperative way: 40 | 41 | .. code-block:: python 42 | 43 | from cornice import resource 44 | 45 | class User(object): 46 | 47 | def __init__(self, request, context=None): 48 | self.request = request 49 | 50 | def __acl__(self): 51 | return [(Allow, Everyone, 'everything')] 52 | 53 | def collection_get(self): 54 | return {'users': _USERS.keys()} 55 | 56 | def get(self): 57 | return _USERS.get(int(self.request.matchdict['id'])) 58 | 59 | resource.add_view(User.get, renderer='json') 60 | user_resource = resource.add_resource(User, collection_path='/users', path='/users/{id}') 61 | 62 | def includeme(config): 63 | config.add_cornice_resource(user_resource) 64 | # or 65 | config.scan("PATH_TO_THIS_MODULE") 66 | 67 | As you can see, you can define methods for the collection (it will use the 68 | **path** argument of the class decorator. When defining collection_* methods, the 69 | path defined in the **collection_path** will be used. 70 | 71 | Here is an example how to reuse existing pyramid routes instead of registering 72 | new ones: 73 | 74 | .. code-block:: python 75 | 76 | @resource(collection_pyramid_route='users', pyramid_route='user') 77 | class User(object): 78 | .... 79 | 80 | Validators and filters 81 | ====================== 82 | 83 | You also can register validators and filters that are defined in your 84 | `@resource` decorated class, like this: 85 | 86 | .. code-block:: python 87 | 88 | from cornice.resource import resource, view 89 | 90 | @resource(path='/users/{id}') 91 | class User(object): 92 | 93 | def __init__(self, request, context=None): 94 | self.request = request 95 | 96 | def __acl__(self): 97 | return [(Allow, Everyone, 'everything')] 98 | 99 | @view(validators=('validate_req',)) 100 | def get(self): 101 | # return the list of users 102 | 103 | def validate_req(self, request): 104 | # validate the request 105 | 106 | 107 | Registered routes 108 | ================= 109 | 110 | Cornice uses a default convention for the names of the routes it registers. 111 | 112 | When defining resources, the pattern used is ``collection_`` (it 113 | prepends ``collection_`` to the service name) for the collection service. 114 | 115 | 116 | Route factory support 117 | ===================== 118 | 119 | When defining a resource, you can provide a `route factory 120 | `_, 121 | just like when defining a pyramid route. Cornice will then pass its result 122 | into the ``__init__`` of your service. 123 | 124 | For example:: 125 | 126 | @resource(path='/users', factory=user_factory) 127 | class User(object): 128 | 129 | def __init__(self, request, context=None): 130 | self.request = request 131 | self.user = context 132 | 133 | When no `factory` is defined, the decorated class becomes the `route factory 134 | `_. 135 | One advantage is that pyramid ACL authorization can be used out of the box: `Resource with ACL 136 | `_. 137 | 138 | For example:: 139 | 140 | @resource(path='/users') 141 | class User(object): 142 | 143 | def __init__(self, request, context=None): 144 | self.request = request 145 | self.user = context 146 | 147 | def __acl__(self): 148 | return [(Allow, Everyone, 'view')] 149 | -------------------------------------------------------------------------------- /docs/source/schema.rst: -------------------------------------------------------------------------------- 1 | Schema validation 2 | ################# 3 | 4 | Validating requests data using a schema is a powerful pattern. 5 | 6 | As you would do for a database table, you define some fields and 7 | their type, and make sure that incoming requests comply. 8 | 9 | There are many schema libraries in the Python ecosystem you can 10 | use. The most known ones are Colander, Marshmallow & formencode. 11 | 12 | You can do schema validation using either those libraries or either 13 | custom code. 14 | 15 | Using a schema is done in 2 steps: 16 | 17 | 1/ linking a schema to your service definition 18 | 2/ implement a validator that uses the schema to verify the request 19 | 20 | Here's a dummy example: 21 | 22 | .. code-block:: python 23 | 24 | def my_validator(request, **kwargs): 25 | schema = kwargs['schema'] 26 | # do something with the schema 27 | 28 | schema = {'id': int, 'name': str} 29 | 30 | @service.post(schema=schema, validators=(my_validator,)) 31 | def post(request): 32 | return {'OK': 1} 33 | 34 | 35 | Cornice will call ``my_validator`` with the incoming request, and will 36 | provide the schema in the keywords. 37 | 38 | 39 | 40 | Using Colander 41 | ============== 42 | 43 | Colander (http://docs.pylonsproject.org/projects/colander/en/latest/) is a 44 | validation framework from the Pylons project that can be used with Cornice's 45 | validation hook to control a request and deserialize its content into 46 | objects. 47 | 48 | Cornice provides a helper to ease Colander integration. 49 | 50 | To describe a schema, using Colander and Cornice, here is how you can do: 51 | 52 | .. code-block:: python 53 | 54 | import colander 55 | 56 | from cornice import Service 57 | from cornice.validators import colander_body_validator 58 | 59 | class SignupSchema(colander.MappingSchema): 60 | username = colander.SchemaNode(colander.String()) 61 | 62 | @signup.post(schema=SignupSchema(), validators=(colander_body_validator,)) 63 | def signup_post(request): 64 | username = request.validated['username'] 65 | return {'success': True} 66 | 67 | .. note:: 68 | 69 | When you use one of ``colander_body_validator``, ``colander_headers_validator``, 70 | ``colander_querystring_validator`` etc. it is necessary to set schema which 71 | inherits from :class:`colander.MappingSchema`. If you need to deserialize 72 | :class:`colander.SequenceSchema` you need to use ``colander_validator`` instead. 73 | 74 | 75 | Using Marshmallow 76 | ================= 77 | 78 | Marshmallow (https://marshmallow.readthedocs.io/en/latest/) 79 | is an ORM/ODM/framework-agnostic library for converting complex 80 | datatypes, such as objects, to and from native Python datatypes that can also 81 | be used with Cornice validation hooks. 82 | 83 | Cornice provides a helper to ease Marshmallow integration. 84 | 85 | To describe a schema, using Marshmallow and Cornice, here is how you can do: 86 | 87 | .. code-block:: python 88 | 89 | import marshmallow 90 | 91 | from cornice import Service 92 | from cornice.validators import marshmallow_body_validator 93 | 94 | class SignupSchema(marshmallow.Schema): 95 | username = marshmallow.fields.String(required=True) 96 | 97 | @signup.post(schema=SignupSchema, validators=(marshmallow_body_validator,)) 98 | def signup_post(request): 99 | username = request.validated['username'] 100 | return {'success': True} 101 | 102 | Dynamic schemas 103 | ~~~~~~~~~~~~~~~ 104 | 105 | If you want to do specific things with the schema at validation step, 106 | like having a schema per request method, you can provide whatever 107 | you want as the schema key and built a custom validator. 108 | 109 | Example: 110 | 111 | .. code-block:: python 112 | 113 | def dynamic_schema(request): 114 | if request.method == 'POST': 115 | schema = foo_schema() 116 | elif request.method == 'PUT': 117 | schema = bar_schema() 118 | return schema 119 | 120 | 121 | def my_validator(request, **kwargs): 122 | kwargs['schema'] = dynamic_schema(request) 123 | return colander_body_validator(request, **kwargs) 124 | 125 | 126 | @service.post(validators=(my_validator,)) 127 | def post(request): 128 | return request.validated 129 | 130 | In addition to ``colander_body_validator()`` as demonstrated above, there are also three more 131 | similar validators, ``colander_headers_validator()``, ``colander_path_validator()``, and 132 | ``colander_querystring_validator()`` (and similarly named ``marshmallow_*`` 133 | functions), which validate the given ``Schema`` against the headers, path, 134 | or querystring parameters, respectively. 135 | 136 | 137 | Multiple request attributes 138 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 139 | 140 | 141 | If you have complex use-cases where data has to be validated across several locations 142 | of the request (like querystring, body etc.), Cornice provides a validator that 143 | takes an additional level of mapping for ``body``, ``querystring``, ``path`` or ``headers`` 144 | instead of the former ``location`` attribute on schema fields. 145 | 146 | The ``request.validated`` hences reflects this additional level. 147 | 148 | .. code-block:: python 149 | 150 | # colander 151 | from cornice.validators import colander_validator 152 | 153 | class Querystring(colander.MappingSchema): 154 | referrer = colander.SchemaNode(colander.String(), missing=colander.drop) 155 | 156 | class Payload(colander.MappingSchema): 157 | username = colander.SchemaNode(colander.String()) 158 | 159 | class SignupSchema(colander.MappingSchema): 160 | body = Payload() 161 | querystring = Querystring() 162 | 163 | signup = cornice.Service() 164 | 165 | @signup.post(schema=SignupSchema(), validators=(colander_validator,)) 166 | def signup_post(request): 167 | username = request.validated['body']['username'] 168 | referrer = request.validated['querystring']['referrer'] 169 | return {'success': True} 170 | 171 | 172 | # marshmallow 173 | from cornice.validators import marshmallow_validator 174 | 175 | class Querystring(marshmallow.Schema): 176 | referrer = marshmallow.fields.String() 177 | 178 | class Payload(marshmallow.Schema): 179 | username = marshmallow.fields.String(validate=[ 180 | marshmallow.validate.Length(min=3) 181 | ], required=True) 182 | 183 | class SignupSchema(marshmallow.Schema): 184 | body = marshmallow.fields.Nested(Payload) 185 | querystring = marshmallow.fields.Nested(Querystring) 186 | 187 | @signup.post(schema=SignupSchema, validators=(marshmallow_validator,)) 188 | def signup_post(request): 189 | username = request.validated['body']['username'] 190 | referrer = request.validated['querystring']['referrer'] 191 | return {'success': True} 192 | 193 | This allows to have validation at the schema level that validates data from several 194 | places on the request: 195 | 196 | .. code-block:: python 197 | 198 | # colander 199 | class SignupSchema(colander.MappingSchema): 200 | body = Payload() 201 | querystring = Querystring() 202 | 203 | def deserialize(self, cstruct=colander.null): 204 | appstruct = super(SignupSchema, self).deserialize(cstruct) 205 | username = appstruct['body']['username'] 206 | referrer = appstruct['querystring'].get('referrer') 207 | if username == referrer: 208 | self.raise_invalid('Referrer cannot be the same as username') 209 | return appstruct 210 | 211 | 212 | # marshmallow 213 | class SignupSchema(marshmallow.Schema): 214 | body = marshmallow.fields.Nested(Payload) 215 | querystring = marshmallow.fields.Nested(Querystring) 216 | 217 | @marshmallow.validates_schema(skip_on_field_errors=True) 218 | def validate_multiple_fields(self, data): 219 | username = data['body'].get('username') 220 | referrer = data['querystring'].get('referrer') 221 | if username == referrer: 222 | raise marshmallow.ValidationError( 223 | 'Referrer cannot be the same as username') 224 | 225 | 226 | Cornice provides built-in support for JSON and HTML forms 227 | (``application/x-www-form-urlencoded``) input validation using the provided 228 | validators. 229 | 230 | If you need to validate other input formats, such as XML, you need to 231 | implement your own deserializer and pass it to the service. 232 | 233 | The general pattern in this case is: 234 | 235 | .. code-block:: python 236 | 237 | from cornice.validators import colander_body_validator 238 | 239 | def my_deserializer(request): 240 | return extract_data_somehow(request) 241 | 242 | 243 | @service.post(schema=MySchema(), 244 | deserializer=my_deserializer, 245 | validators=(colander_body_validator,)) 246 | def post(request): 247 | return {'OK': 1} 248 | 249 | 250 | Marshmallow schemas have access to request as context object which can be handy 251 | for things like CSRF validation: 252 | 253 | .. code-block:: python 254 | 255 | class MNeedsContextSchema(marshmallow.Schema): 256 | somefield = marshmallow.fields.Float(missing=lambda: random.random()) 257 | csrf_secret = marshmallow.fields.String() 258 | 259 | @marshmallow.validates_schema 260 | def validate_csrf_secret(self, data): 261 | # simulate validation of session variables 262 | if self.context['request'].get_csrf() != data.get('csrf_secret'): 263 | raise marshmallow.ValidationError('Wrong token') 264 | 265 | 266 | 267 | Using formencode 268 | ================ 269 | 270 | FormEncode (http://www.formencode.org/en/latest/index.html) is yet another 271 | validation system that can be used with Cornice. 272 | 273 | For example, if you want to make sure the optional query option **max** 274 | is an integer, and convert it, you can use FormEncode in a Cornice validator 275 | like this: 276 | 277 | .. code-block:: python 278 | 279 | from formencode import validators 280 | 281 | from cornice import Service 282 | from cornice.validators import extract_cstruct 283 | 284 | foo = Service(name='foo', path='/foo') 285 | 286 | def form_validator(request, **kwargs): 287 | data = extract_cstruct(request) 288 | validator = validators.Int() 289 | try: 290 | max = data['querystring'].get('max') 291 | request.validated['max'] = validator.to_python(max) 292 | except formencode.Invalid, e: 293 | request.errors.add('querystring', 'max', e.message) 294 | 295 | @foo.get(validators=(form_validator,)) 296 | def get_value(request): 297 | """Returns the value. 298 | """ 299 | return {'posted': request.validated} 300 | 301 | See also 302 | ======== 303 | 304 | Several libraries exist in the wild to validate data in Python and that can easily 305 | be plugged with Cornice. 306 | 307 | * JSONSchema (https://pypi.python.org/pypi/jsonschema) 308 | * Cerberus (https://pypi.python.org/pypi/Cerberus) 309 | * marshmallow (https://pypi.python.org/pypi/marshmallow) 310 | -------------------------------------------------------------------------------- /docs/source/services.rst: -------------------------------------------------------------------------------- 1 | Defining services 2 | ################# 3 | 4 | As mentioned in the :ref:`quickstart` and :ref:`tutorial`, services are defined 5 | this way: 6 | 7 | .. code-block:: python 8 | 9 | from cornice import Service 10 | 11 | flush = Service(name='flush', 12 | description='Clear database content', 13 | path='/__flush__') 14 | 15 | @flush.post() 16 | def flush_post(request): 17 | return {"Done": True} 18 | 19 | Example above registers new routes in pyramid registry, you might want to 20 | reuse existing routes from your application too. 21 | 22 | .. code-block:: python 23 | 24 | from cornice import Service 25 | 26 | flush = Service(name='flush', 27 | description='Clear database content', 28 | pyramid_route='flush_path') 29 | 30 | See :class:`cornice.service.Service` for an exhaustive list of options. 31 | 32 | Imperatively 33 | ============ 34 | 35 | Here is an example of how to define cornice services in an imperative way: 36 | 37 | .. code-block:: python 38 | 39 | def flush_post(request): 40 | return {"Done": True} 41 | 42 | flush = Service(name='flush', 43 | description='Clear database content', 44 | path='/__flush__') 45 | 46 | flush.add_view("POST", flush_post) 47 | 48 | def includeme(config): 49 | config.add_cornice_service(flush) 50 | # or 51 | config.scan("PATH_TO_THIS_MODULE") 52 | 53 | 54 | Custom error handler 55 | ==================== 56 | 57 | .. code-block:: python 58 | 59 | from pyramid.httpexceptions import HTTPBadRequest 60 | 61 | def my_error_handler(request): 62 | first_error = request.errors[0] 63 | body = {'description': first_error['description']} 64 | 65 | response = HTTPBadRequest() 66 | response.body = json.dumps(body).encode("utf-8") 67 | response.content_type = 'application/json' 68 | return response 69 | 70 | flush = Service(name='flush', 71 | path='/__flush__', 72 | error_handler=my_error_handler) 73 | 74 | 75 | .. _service-cors: 76 | 77 | CORS 78 | ==== 79 | 80 | When enabling CORS, Cornice will take automatically define ``OPTIONS`` views 81 | and appropriate headers validation. 82 | 83 | .. code-block:: python 84 | 85 | flush = Service(name='flush', 86 | description='Clear database content', 87 | path='/__flush__', 88 | cors_origins=('*',), 89 | cors_max_age=3600) 90 | 91 | 92 | .. note:: 93 | 94 | When ``*`` is among CORS origins and the setting ``cornice.always_cors`` is set to ``true``, 95 | then CORS response headers are always returned. 96 | 97 | There are also a number of parameters that are related to the support of 98 | CORS (Cross Origin Resource Sharing). You can read the CORS specification 99 | at http://www.w3.org/TR/cors/ and see :class:`the exhaustive list of options in Cornice `. 100 | 101 | .. seealso:: 102 | 103 | https://blog.mozilla.org/services/2013/02/04/implementing-cross-origin-resource-sharing-cors-for-cornice/ 104 | 105 | 106 | Route factory support 107 | ===================== 108 | 109 | When defining a service, you can provide a `route factory 110 | `_, 111 | just like when defining a pyramid route. 112 | 113 | For example:: 114 | 115 | flush = Service(name='flush', path='/__flush__', factory=user_factory) 116 | -------------------------------------------------------------------------------- /docs/source/testing.rst: -------------------------------------------------------------------------------- 1 | Testing 2 | ======= 3 | 4 | Running tests 5 | ------------- 6 | 7 | To run all tests in all Python environments configured in ``tox.ini``, 8 | just setup ``tox`` and run it inside the toplevel project directory:: 9 | 10 | tox 11 | 12 | To run a single test inside a specific Python environment, do e.g.:: 13 | 14 | tox -e py39 tests/test_validation.py::TestServiceDefinition::test_content_type_missing 15 | 16 | 17 | Testing cornice services 18 | ------------------------ 19 | 20 | Testing is nice and useful. Some folks even said it helped saving kittens. And 21 | childs. Here is how you can test your Cornice's applications. 22 | 23 | Let's suppose you have this service definition: 24 | 25 | .. code-block:: python 26 | 27 | from pyramid.config import Configurator 28 | 29 | from cornice import Service 30 | 31 | service = Service(name="service", path="/service") 32 | 33 | 34 | def has_payed(request, **kwargs): 35 | if not 'paid' in request.GET: 36 | request.errors.add('body', 'paid', 'You must pay!') 37 | 38 | 39 | @service.get(validators=(has_payed,)) 40 | def get1(request): 41 | return {"test": "succeeded"} 42 | 43 | 44 | def includeme(config): 45 | config.include("cornice") 46 | config.scan("absolute.path.to.this.service") 47 | 48 | 49 | def main(global_config, **settings): 50 | config = Configurator(settings={}) 51 | config.include(includeme) 52 | return config.make_wsgi_app() 53 | 54 | 55 | We have done three things here: 56 | 57 | * setup a service, using the `Service` class and define our services with it 58 | * register the app and cornice to pyramid in the `includeme` function 59 | * define a `main` function to be used in tests 60 | 61 | To test this service, we will use **webtest**, and the `TestApp` class: 62 | 63 | .. code-block:: python 64 | 65 | from webtest import TestApp 66 | import unittest 67 | 68 | from yourapp import main 69 | 70 | class TestYourApp(unittest.TestCase): 71 | 72 | def test_case(self): 73 | app = TestApp(main({})) 74 | app.get('/service', status=400) 75 | -------------------------------------------------------------------------------- /docs/source/tutorial.rst: -------------------------------------------------------------------------------- 1 | .. _tutorial: 2 | 3 | Full tutorial 4 | ============= 5 | 6 | Let's create a full working application with **Cornice**. We want to 7 | create a light messaging service. 8 | 9 | You can find its whole source code at https://github.com/Cornices/examples/blob/main/messaging 10 | 11 | Features: 12 | 13 | - users can register to the service 14 | - users can list all registered users 15 | - users can send messages 16 | - users can retrieve the latest messages 17 | - messages have three fields: sender, content, color (red or black) 18 | - adding a message is done through authentication 19 | 20 | Limitations: 21 | 22 | - there's a single channel for all messages. 23 | - if a user with the same name is already registered, 24 | he cannot register. 25 | - all messages and users are kept in memory. 26 | 27 | 28 | Design 29 | ------ 30 | 31 | The application provides two services: 32 | 33 | - **users**, at **/users**: where you can list all users or register a new one 34 | - **messages**, at **/**: where you can read the messages or add new ones 35 | 36 | On the server, the data is kept in memory. 37 | 38 | We'll provide a single CLI client in Python, using Curses. 39 | 40 | 41 | Setting up the development environment 42 | -------------------------------------- 43 | 44 | To begin, create a new directory and environment:: 45 | 46 | $ mkdir messaging 47 | $ cd messaging 48 | $ python3 -m venv ./ 49 | 50 | Once you have it, install Cornice in it with Pip:: 51 | 52 | $ bin/pip install cornice 53 | 54 | You'll also need **waitress** (see https://pypi.python.org/pypi/waitress):: 55 | 56 | $ bin/pip install waitress 57 | 58 | We provide a `Cookiecutter `_ template you 59 | can use to create a new application:: 60 | 61 | $ bin/pip install cookiecutter 62 | $ bin/cookiecutter gh:Cornices/cookiecutter-cornice 63 | repo_name [myapp]: messaging 64 | project_title [My Cornice application.]: Cornice tutorial 65 | 66 | Once your application is generated, go there and call *develop* against it:: 67 | 68 | $ cd messaging 69 | $ ../bin/python setup.py develop 70 | ... 71 | 72 | The application can now be launched via embedded Pyramid ``pserve``, it provides a default "Hello" 73 | service check:: 74 | 75 | $ ../bin/pserve messaging.ini 76 | Starting server in PID 7618. 77 | serving on 0.0.0.0:6543 view at http://127.0.0.1:6543 78 | 79 | Once the application is running, visit http://127.0.0.1:6543 in your browser and make sure you get:: 80 | 81 | {'Hello': 'World'} 82 | 83 | You should also get the same results calling the URL via Curl:: 84 | 85 | $ curl -i http://0.0.0.0:6543/ 86 | 87 | This will result:: 88 | 89 | HTTP/1.1 200 OK 90 | Content-Length: 18 91 | Content-Type: application/json; charset=UTF-8 92 | Date: Tue, 12 May 2015 13:23:32 GMT 93 | Server: waitress 94 | 95 | {"Hello": "World"} 96 | 97 | 98 | Defining the services 99 | --------------------- 100 | 101 | Let's open the file in :file:`messaging/views.py`, it contains all the Services:: 102 | 103 | from cornice import Service 104 | 105 | hello = Service(name='hello', path='/', description="Simplest app") 106 | 107 | @hello.get() 108 | def get_info(request): 109 | """Returns Hello in JSON.""" 110 | return {'Hello': 'World'} 111 | 112 | 113 | Users management 114 | :::::::::::::::: 115 | 116 | 117 | We're going to get rid of the Hello service, and change this file in order 118 | to add our first service - the users management 119 | 120 | .. code-block:: python 121 | 122 | from cornice import Service 123 | 124 | _USERS = {} 125 | 126 | users = Service(name='users', path='/users', description="User registration") 127 | 128 | @users.get(validators=valid_token) 129 | def get_users(request): 130 | """Returns a list of all users.""" 131 | return {'users': list(_USERS)} 132 | 133 | @users.post(validators=unique) 134 | def create_user(request): 135 | """Adds a new user.""" 136 | user = request.validated['user'] 137 | _USERS[user['name']] = user['token'] 138 | return {'token': '%s-%s' % (user['name'], user['token'])} 139 | 140 | @users.delete(validators=valid_token) 141 | def delete_user(request): 142 | """Removes the user.""" 143 | name = request.validated['user'] 144 | del _USERS[name] 145 | return {'Goodbye': name} 146 | 147 | 148 | What we have here is 3 methods on **/users**: 149 | 150 | - **GET**: returns the list of users names -- the keys of _USERS 151 | - **POST**: adds a new user and returns a unique token 152 | - **DELETE**: removes the user. 153 | 154 | Remarks: 155 | 156 | - **POST** uses the **unique** validator to make sure that the user 157 | name is not already taken. That validator is also in charge of 158 | generating a unique token associated with the user. 159 | - **GET** users the **valid_token** to verify that a **X-Messaging-Token** 160 | header is provided in the request, with a valid token. That also identifies 161 | the user. 162 | - **DELETE** also identifies the user then removes it. 163 | 164 | These methods will use validators to fill the **request.validated** 165 | mapping. Add the following code to :file:`messaging/views.py`: 166 | 167 | .. code-block:: python 168 | 169 | import os 170 | import binascii 171 | 172 | from pyramid.httpexceptions import HTTPUnauthorized, HTTPConflict 173 | 174 | 175 | def _create_token(): 176 | return binascii.b2a_hex(os.urandom(20)).decode('utf-8') 177 | 178 | 179 | def valid_token(request, **kargs): 180 | header = 'X-Messaging-Token' 181 | htoken = request.headers.get(header) 182 | if htoken is None: 183 | raise HTTPUnauthorized() 184 | try: 185 | user, token = htoken.split('-', 1) 186 | except ValueError: 187 | raise HTTPUnauthorized() 188 | 189 | valid = user in _USERS and _USERS[user] == token 190 | if not valid: 191 | raise HTTPUnauthorized() 192 | 193 | request.validated['user'] = user 194 | 195 | 196 | def unique(request, **kargs): 197 | name = request.text 198 | if name in _USERS: 199 | request.errors.add('url', 'name', 'This user exists!') 200 | raise HTTPConflict() 201 | user = {'name': name, 'token': _create_token()} 202 | request.validated['user'] = user 203 | 204 | 205 | The validators work by filling the **request.validated** 206 | dictionary. When the validator finds errors, it adds them to the 207 | **request.errors** dictionary and raises a Conflict (409) error. 208 | Note that raising an error here still honors Cornice's built-in 209 | `request errors `_. 210 | 211 | Let's try our application so far with CURL:: 212 | 213 | 214 | $ curl http://localhost:6543/users 215 | {"status": 401, "message": "Unauthorized"} 216 | 217 | $ curl -X POST http://localhost:6543/users -d 'tarek' 218 | {"token": "tarek-a15fa2ea620aac8aad3e1b97a64200ed77dc7524"} 219 | 220 | $ curl http://localhost:6543/users -H "X-Messaging-Token:tarek-a15fa2ea620aac8aad3e1b97a64200ed77dc7524" 221 | {"users": ["tarek"]} 222 | 223 | $ curl -X DELETE http://localhost:6543/users -H "X-Messaging-Token:tarek-a15fa2ea620aac8aad3e1b97a64200ed77dc7524" 224 | {"Goodbye": "tarek"} 225 | 226 | 227 | 228 | Messages management 229 | ::::::::::::::::::: 230 | 231 | Now that we have users, let's post and get messages. This is done via two very 232 | simple functions we're adding in the :file:`views.py` file: 233 | 234 | .. code-block:: python 235 | 236 | _MESSAGES = [] 237 | 238 | messages = Service(name='messages', path='/', description="Messages") 239 | 240 | @messages.get() 241 | def get_messages(request): 242 | """Returns the 5 latest messages""" 243 | return _MESSAGES[:5] 244 | 245 | 246 | @messages.post(validators=(valid_token, valid_message)) 247 | def post_message(request): 248 | """Adds a message""" 249 | _MESSAGES.insert(0, request.validated['message']) 250 | return {'status': 'added'} 251 | 252 | 253 | 254 | The first one simply returns the five first messages in a list, and the second 255 | one inserts a new message in the beginning of the list. 256 | 257 | The **POST** uses two validators: 258 | 259 | - :func:`valid_token`: the function we used previously that makes sure the 260 | user is registered 261 | - :func:`valid_message`: a function that looks at the message provided in the 262 | POST body, and puts it in the validated dict. 263 | 264 | 265 | Here's the :func:`valid_message` function: 266 | 267 | .. code-block:: python 268 | 269 | import json 270 | 271 | def valid_message(request): 272 | try: 273 | message = json.loads(request.body) 274 | except ValueError: 275 | request.errors.add('body', 'message', 'Not valid JSON') 276 | return 277 | 278 | # make sure we have the fields we want 279 | if 'text' not in message: 280 | request.errors.add('body', 'text', 'Missing text') 281 | return 282 | 283 | if 'color' in message and message['color'] not in ('red', 'black'): 284 | request.errors.add('body', 'color', 'only red and black supported') 285 | elif 'color' not in message: 286 | message['color'] = 'black' 287 | 288 | message['user'] = request.validated['user'] 289 | request.validated['message'] = message 290 | 291 | 292 | This function extracts the json body, then checks that it contains a text key 293 | at least. It adds a color or use the one that was provided, 294 | and reuse the user name provided by the previous validator 295 | with the token control. 296 | 297 | 298 | The Client 299 | ---------- 300 | 301 | A simple client to use against our service can do three things: 302 | 303 | 1. let the user register a name 304 | 2. poll for the latest messages 305 | 3. let the user send a message ! 306 | 307 | Without going into great details, there's a Python CLI against messaging 308 | that uses Curses. 309 | 310 | See https://github.com/Cornices/examples/blob/main/messaging/messaging/client.py 311 | -------------------------------------------------------------------------------- /docs/source/upgrading.rst: -------------------------------------------------------------------------------- 1 | Upgrading 2 | ######### 3 | 4 | 4.X to 5.X 5 | ========== 6 | 7 | Upgrade your codebase to Python 3. 8 | 9 | In order to keep using ``simplejson`` with this release, add it explicitly as your project dependencies, and set it explicitly as the default renderer: 10 | 11 | .. code-block:: python 12 | 13 | import simplejson 14 | from cornice.render import CorniceRenderer 15 | 16 | class SimpleJSONRenderer(CorniceRenderer): 17 | def __init__(self, **kwargs): 18 | kwargs["serializer"] = simplejson.dumps 19 | 20 | config.add_renderer(None, SimpleJSONRenderer()) 21 | 22 | See https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/renderers.html 23 | 24 | 25 | 3.X to 4.X 26 | ========== 27 | 28 | ``request.validated`` is now always a ``colander.MappingSchema`` instance (``dict``) when using ``colander_*_validator()`` functions. 29 | 30 | In order to use a different type (eg. ``SequenceSchema``), use ``colander_validator()`` and read it from ``request.validated['body']``. 31 | 32 | 33 | 2.X to 3.X 34 | ========== 35 | 36 | ``acl`` and ``traverse`` parameters are no longer supported. ``ConfigurationError`` 37 | will be raised if either one is specified. 38 | 39 | A class decorated with `@resource` or `@service` becomes its own 40 | `route factory 41 | `_ and 42 | it may take advantage of `Pyramid ACL 43 | `_. 44 | 45 | Before: 46 | 47 | .. code-block:: python 48 | 49 | def custom_acl(request): 50 | return [(Allow, Everyone, 'view')] 51 | 52 | @resource(path='/users', acl=custom_acl, collection_acl=custom_acl) 53 | class User(object): 54 | 55 | def __init__(self, request, context=None): 56 | self.request = request 57 | self.context = context 58 | 59 | Now: 60 | 61 | .. code-block:: python 62 | 63 | @resource(path='/users') 64 | class User(object): 65 | 66 | def __init__(self, request, context=None): 67 | self.request = request 68 | self.context = context 69 | 70 | def __acl__(self): 71 | return [(Allow, Everyone, 'view')] 72 | 73 | 1.X to 2.X 74 | ========== 75 | 76 | Project template 77 | ---------------- 78 | 79 | We now rely on `Cookiecutter `_ instead of 80 | the deprecated Pyramid scaffolding feature:: 81 | 82 | $ cookiecutter gh:Cornices/cookiecutter-cornice 83 | 84 | Sphinx documentation 85 | -------------------- 86 | 87 | The Sphinx extension now lives in a separate package, that must be installed:: 88 | 89 | pip install cornice_sphinx 90 | 91 | Before in your :file:`docs/conf.py`: 92 | 93 | .. code-block: python 94 | 95 | import cornice 96 | 97 | sys.path.insert(0, os.path.abspath(cornice.__file__)) 98 | extensions = ['cornice.ext.sphinxext'] 99 | 100 | Now: 101 | 102 | .. code-block: python 103 | 104 | import cornice_sphinx 105 | 106 | sys.path.insert(0, os.path.abspath(cornice_sphinx.__file__)) 107 | extensions = ['cornice_sphinx'] 108 | 109 | 110 | Validators 111 | ---------- 112 | 113 | Validators now receive the kwargs of the related service definition. 114 | 115 | Before: 116 | 117 | .. code-block:: python 118 | 119 | def has_payed(request): 120 | if 'paid' not in request.GET: 121 | request.errors.add('body', 'paid', 'You must pay!') 122 | 123 | Now: 124 | 125 | .. code-block:: python 126 | 127 | def has_payed(request, **kwargs): 128 | free_access = kwargs.get('free_access') 129 | if not free_access and 'paid' not in request.GET: 130 | request.errors.add('body', 'paid', 'You must pay!') 131 | 132 | 133 | Colander validation 134 | ------------------- 135 | 136 | Colander schema validation now requires an explicit validator on the service 137 | view definition. 138 | 139 | Before: 140 | 141 | .. code-block:: python 142 | 143 | class SignupSchema(colander.MappingSchema): 144 | username = colander.SchemaNode(colander.String()) 145 | 146 | @signup.post(schema=SignupSchema) 147 | def signup_post(request): 148 | username = request.validated['username'] 149 | return {'success': True} 150 | 151 | Now: 152 | 153 | .. code-block:: python 154 | 155 | from cornice.validators import colander_body_validator 156 | 157 | class SignupSchema(colander.MappingSchema): 158 | username = colander.SchemaNode(colander.String()) 159 | 160 | @signup.post(schema=SignupSchema(), validators=(colander_body_validator,)) 161 | def signup_post(request): 162 | username = request.validated['username'] 163 | return {'success': True} 164 | 165 | This makes declarations a bit more verbose, but decorrelates Cornice from Colander. 166 | Now any validation library can be used. 167 | 168 | .. important:: 169 | 170 | Some of the validation messages may have changed from version 1.2. 171 | For example ``Invalid escape sequence`` becomes ``Invalid \\uXXXX escape``. 172 | 173 | 174 | Complex Colander validation 175 | --------------------------- 176 | 177 | If you have complex use-cases where data has to be validated across several locations 178 | of the request (like querystring, body etc.), Cornice provides a validator that 179 | takes an additional level of mapping for ``body``, ``querystring``, ``path`` or ``headers`` 180 | instead of the former ``location`` attribute on schema fields. 181 | 182 | The ``request.validated`` hence reflects this additional level. 183 | 184 | Before: 185 | 186 | .. code-block:: python 187 | 188 | class SignupSchema(colander.MappingSchema): 189 | username = colander.SchemaNode(colander.String(), location='body') 190 | referrer = colander.SchemaNode(colander.String(), location='querystring', 191 | missing=colander.drop) 192 | 193 | @signup.post(schema=SignupSchema) 194 | def signup_post(request): 195 | username = request.validated['username'] 196 | referrer = request.validated['referrer'] 197 | return {'success': True} 198 | 199 | Now: 200 | 201 | .. code-block:: python 202 | 203 | from cornice.validators import colander_validator 204 | 205 | class Querystring(colander.MappingSchema): 206 | referrer = colander.SchemaNode(colander.String(), missing=colander.drop) 207 | 208 | class Payload(colander.MappingSchema): 209 | username = colander.SchemaNode(colander.String()) 210 | 211 | class SignupSchema(colander.MappingSchema): 212 | body = Payload() 213 | querystring = Querystring() 214 | 215 | signup = cornice.Service() 216 | 217 | @signup.post(schema=SignupSchema(), validators=(colander_validator,)) 218 | def signup_post(request): 219 | username = request.validated['body']['username'] 220 | referrer = request.validated['querystring']['referrer'] 221 | return {'success': True} 222 | 223 | This now allows to have validation at the schema level that validates data from several 224 | locations: 225 | 226 | .. code-block:: python 227 | 228 | class SignupSchema(colander.MappingSchema): 229 | body = Payload() 230 | querystring = Querystring() 231 | 232 | def deserialize(self, cstruct=colander.null): 233 | appstruct = super(SignupSchema, self).deserialize(cstruct) 234 | username = appstruct['body']['username'] 235 | referrer = appstruct['querystring'].get('referrer') 236 | if username == referrer: 237 | self.raise_invalid('Referrer cannot be the same as username') 238 | return appstruct 239 | 240 | Deferred validators 241 | ------------------- 242 | 243 | Colander deferred validators allow to access runtime objects during validation, 244 | like the current request for example. 245 | 246 | Before, the binding to the request was implicitly done by Cornice, and now has 247 | to be explicit. 248 | 249 | .. code-block:: python 250 | 251 | import colander 252 | 253 | @colander.deferred 254 | def deferred_validator(node, kw): 255 | request = kw['request'] 256 | if request['x-foo'] == 'version_a': 257 | return colander.OneOf(['a', 'b']) 258 | else: 259 | return colander.OneOf(['c', 'd']) 260 | 261 | class Schema(colander.MappingSchema): 262 | bazinga = colander.SchemaNode(colander.String(), validator=deferred_validator) 263 | 264 | Before: 265 | 266 | .. code-block:: python 267 | 268 | signup = cornice.Service() 269 | 270 | @signup.post(schema=Schema()) 271 | def signup_post(request): 272 | return {} 273 | 274 | After: 275 | 276 | .. code-block:: python 277 | 278 | def bound_schema_validator(request, **kwargs): 279 | schema = kwargs['schema'] 280 | kwargs['schema'] = schema.bind(request=request) 281 | return colander_validator(request, **kwargs) 282 | 283 | signup = cornice.Service() 284 | 285 | @signup.post(schema=Schema(), validators=(bound_schema_validator,)) 286 | def signup_post(request): 287 | return {} 288 | 289 | 290 | Error handler 291 | ------------- 292 | 293 | * The ``error_handler`` callback of services now receives a ``request`` object instead of ``errors``. 294 | 295 | Before: 296 | 297 | .. code-block:: python 298 | 299 | def xml_error(errors): 300 | request = errors.request 301 | ... 302 | 303 | Now: 304 | 305 | .. code-block:: python 306 | 307 | def xml_error(request): 308 | errors = request.errors 309 | ... 310 | 311 | 312 | Deserializers 313 | ------------- 314 | 315 | The support of ``config.add_deserializer()`` and ``config.registry.cornice_deserializers`` 316 | was dropped. 317 | 318 | Deserializers are still defined via the same API: 319 | 320 | .. code-block:: python 321 | 322 | def dummy_deserializer(request): 323 | if request.headers.get("Content-Type") == "text/dummy": 324 | values = request.body.decode().split(',') 325 | return dict(zip(['foo', 'bar', 'yeah'], values)) 326 | request.errors.add(location='body', description='Unsupported content') 327 | 328 | @myservice.post(schema=FooBarSchema(), 329 | deserializer=dummy_deserializer, 330 | validators=(my_validator,)) 331 | 332 | But now, instead of using the application registry, the ``deserializer`` is 333 | accessed via the validator kwargs: 334 | 335 | .. code-block:: python 336 | 337 | from cornice.validators import extract_cstruct 338 | 339 | def my_validator(request, deserializer=None, **kwargs): 340 | if deserializer is None: 341 | deserializer = extract_cstruct 342 | data = deserializer(request) 343 | ... 344 | 345 | .. note:: 346 | 347 | The built-in ``colander_validator`` supports custom deserializers and defaults 348 | to the built-in JSON deserializer ``cornice.validators.extract_cstruct``. 349 | 350 | .. note:: 351 | 352 | The attributes ``registry.cornice_deserializers`` and ``request.deserializer`` 353 | are not set anymore. 354 | 355 | 356 | Services schemas introspection 357 | ------------------------------ 358 | 359 | The ``schema`` argument of services is now treated as service kwarg. 360 | The ``service.schemas_for()`` method was dropped as well as the ``service.schemas`` 361 | property. 362 | 363 | Before: 364 | 365 | .. code-block:: python 366 | 367 | schema = service.schemas_for(method="POST") 368 | 369 | Now: 370 | 371 | .. code-block:: python 372 | 373 | schema = [kwargs['schema'] for method, view, kwargs in service.definitions 374 | if method == "POST"][0] 375 | -------------------------------------------------------------------------------- /docs/source/validation.rst: -------------------------------------------------------------------------------- 1 | Validation features 2 | ################### 3 | 4 | Cornice provides a way to control the request before it's passed to the 5 | code. A validator is a simple callable that gets the request object and 6 | some keywords arguments, and fills **request.errors** in case the request 7 | isn't valid. 8 | 9 | Validators can also convert values and saves them so they can be reused 10 | by the code. This is done by filling the **request.validated** dictionary. 11 | 12 | Once the request had been sent to the view, you can filter the results using so 13 | called filters. This document describe both concepts, and how to deal with 14 | them. 15 | 16 | 17 | Disabling or adding filters/validators 18 | ====================================== 19 | 20 | Some validators and filters are activated by default, for all the services. In 21 | case you want to disable them, or if you 22 | 23 | You can register a filter for all the services by tweaking the `DEFAULT_FILTER` 24 | parameter: 25 | 26 | .. code-block:: python 27 | 28 | from cornice.validators import DEFAULT_FILTERS 29 | 30 | def includeme(config): 31 | DEFAULT_FILTERS.append(your_callable) 32 | 33 | (this also works for validators) 34 | 35 | You also can add or remove filters and validators for a particular service. To 36 | do that, you need to define its `default_validators` and `default_filters` 37 | class parameters. 38 | 39 | 40 | Dealing with errors 41 | =================== 42 | 43 | When validating inputs using the different validation mechanisms (described in 44 | this document), Cornice can return errors. In case it returns errors, it will 45 | do so in JSON by default. 46 | 47 | The default returned JSON object is a dictionary of the following form:: 48 | 49 | { 50 | 'status': 'error', 51 | 'errors': errors 52 | } 53 | 54 | 55 | With ``errors`` being a JSON dictionary with the keys "location", "name" and 56 | "description". 57 | 58 | * **location** is the location of the error. It can be "querystring", "header" 59 | or "body" 60 | * **name** is the eventual name of the value that caused problems 61 | * **description** is a description of the problem encountered. 62 | 63 | You can override the default JSON error handler for a view with your own 64 | callable. The following function, for instance, returns the error response 65 | with an XML document as its payload: 66 | 67 | .. code-block:: python 68 | 69 | def xml_error(request): 70 | errors = request.errors 71 | lines = [''] 72 | for error in errors: 73 | lines.append('' 74 | '%(location)s' 75 | '%(name)s' 76 | '%(description)s' 77 | '' % error) 78 | lines.append('') 79 | return HTTPBadRequest(body=''.join(lines), 80 | content_type='application/xml') 81 | 82 | Configure your views by passing your handler as ``error_handler``: 83 | 84 | .. code-block:: python 85 | 86 | @service.post(validators=my_validator, error_handler=xml_error) 87 | def post(request): 88 | return {'OK': 1} 89 | 90 | 91 | Validators 92 | ========== 93 | 94 | Cornice provide a simple mechanism to let you validate incoming requests 95 | before they are processed by your views. 96 | 97 | 98 | Validation using custom callables 99 | --------------------------------- 100 | 101 | Let's take an example: we want to make sure the incoming request has an 102 | **X-Verified** header. If not, we want the server to return a 400: 103 | 104 | .. code-block:: python 105 | 106 | from cornice import Service 107 | 108 | foo = Service(name='foo', path='/foo') 109 | 110 | 111 | def has_paid(request, **kwargs): 112 | if not 'X-Verified' in request.headers: 113 | request.errors.add('header', 'X-Verified', 'You need to provide a token') 114 | 115 | @foo.get(validators=has_paid) 116 | def get_value(request): 117 | """Returns the value. 118 | """ 119 | return 'Hello' 120 | 121 | 122 | Notice that you can chain the validators by passing a sequence 123 | to the **validators** option. 124 | 125 | 126 | Changing the status code from validators 127 | ---------------------------------------- 128 | 129 | You also can change the status code returned from your validators. Here is an 130 | example of this: 131 | 132 | .. code-block:: python 133 | 134 | def user_exists(request): 135 | if not request.POST['userid'] in userids: 136 | request.errors.add('body', 'userid', 'The user id does not exist') 137 | request.errors.status = 404 138 | 139 | Doing validation and filtering at class level 140 | --------------------------------------------- 141 | 142 | If you want to use class methods to do validation, you can do so by passing the 143 | `klass` parameter to the `hook_view` or `@method` decorators, plus a string 144 | representing the name of the method you want to invoke on validation. 145 | 146 | Take care, though, because this only works if the class you are using has an 147 | `__init__` method which takes a `request` as the first argument. 148 | 149 | This means something like this: 150 | 151 | .. code-block:: python 152 | 153 | class MyClass(object): 154 | def __init__(self, request): 155 | self.request = request 156 | 157 | def validate_it(self, request, **kw): 158 | # pseudo-code validation logic 159 | if whatever is wrong: 160 | request.errors.add('body', description="Something is wrong") 161 | 162 | @service.get(klass=MyClass, validators=('validate_it',)) 163 | def view(request): 164 | return "ok" 165 | 166 | 167 | Media type validation 168 | ===================== 169 | 170 | There are two flavors of media/content type validations Cornice can apply to services: 171 | 172 | - :ref:`content-negotiation` checks if Cornice is able to respond with an appropriate 173 | **response body** content type requested by the client sending an ``Accept`` header. 174 | Otherwise it will croak with a ``406 Not Acceptable``. 175 | 176 | - :ref:`request-media-type` validation will match the ``Content-Type`` **request header** 177 | designating the **request body** content type against a list of allowed content types. 178 | When failing on that, it will croak with a ``415 Unsupported Media Type``. 179 | 180 | .. _content-negotiation: 181 | 182 | Content negotiation 183 | ------------------- 184 | Validate the ``Accept`` header in http requests 185 | against a defined or computed list of internet media types. 186 | Otherwise, signal ``406 Not Acceptable`` to the client. 187 | 188 | Basics 189 | ~~~~~~ 190 | By passing the `accept` argument to the service definition decorator, 191 | we define the media types we can generate http **response** bodies for:: 192 | 193 | @service.get(accept="text/html") 194 | def foo(request): 195 | return 'Foo' 196 | 197 | When doing this, Cornice automatically deals with egress content negotiation for you. 198 | 199 | If services don't render one of the appropriate response body formats asked 200 | for by the requests HTTP **Accept** header, Cornice will respond with a http 201 | status of ``406 Not Acceptable``. 202 | 203 | The `accept` argument can either be a string or a list of accepted values 204 | made of internet media type(s) or a callable returning the same. 205 | 206 | Using callables 207 | ~~~~~~~~~~~~~~~ 208 | 209 | When a callable is specified, it is called *before* the 210 | request is passed to the destination function, with the `request` object as 211 | an argument. 212 | 213 | The callable obtains the request object and returns a list or a single scalar 214 | value of accepted media types: 215 | 216 | .. code-block:: python 217 | 218 | def _accept(request): 219 | # interact with request if needed 220 | return ("text/xml", "application/json") 221 | 222 | @service.get(accept=_accept) 223 | def foo(request): 224 | return 'Foo' 225 | 226 | .. seealso:: https://developer.mozilla.org/en-US/docs/HTTP/Content_negotiation 227 | 228 | Error responses 229 | ~~~~~~~~~~~~~~~ 230 | When requests are rejected, an appropriate error response 231 | is sent to the client using the configured `error_handler`. 232 | To give the service consumer a hint about the valid internet 233 | media types to use for the ``Accept`` header, 234 | the error response contains a list of allowed types. 235 | 236 | When using the default json `error_handler`, the response might look like this:: 237 | 238 | { 239 | 'status': 'error', 240 | 'errors': [ 241 | { 242 | 'location': 'header', 243 | 'name': 'Accept', 244 | 'description': 'Accept header should be one of ["text/xml", "application/json"]' 245 | } 246 | ] 247 | } 248 | 249 | .. _content-type-validation: 250 | .. _request-media-type: 251 | 252 | Request media type 253 | ------------------ 254 | 255 | Validate the ``Content-Type`` header in http requests 256 | against a defined or computed list of internet media types. 257 | Otherwise, signal ``415 Unsupported Media Type`` to the client. 258 | 259 | 260 | Basics 261 | ~~~~~~ 262 | 263 | By passing the `content_type` argument to the service definition decorator, 264 | we define the media types we accept as http **request** bodies: 265 | 266 | .. code-block:: python 267 | 268 | @service.post(content_type="application/json") 269 | def foo(request): 270 | return 'Foo' 271 | 272 | All requests sending a different internet media type 273 | using the HTTP **Content-Type** header will be rejected 274 | with a http status of ``415 Unsupported Media Type``. 275 | 276 | The `content_type` argument can either be a string or a list of accepted values 277 | made of internet media type(s) or a callable returning the same. 278 | 279 | 280 | Using callables 281 | ~~~~~~~~~~~~~~~ 282 | 283 | When a callable is specified, it is called *before* the 284 | request is passed to the destination function, with the `request` object as 285 | an argument. 286 | 287 | The callable obtains the request object and returns a list or a single scalar 288 | value of accepted media types: 289 | 290 | .. code-block:: python 291 | 292 | def _content_type(request): 293 | # interact with request if needed 294 | return ("text/xml", "application/json") 295 | 296 | @service.post(content_type=_content_type) 297 | def foo(request): 298 | return 'Foo' 299 | 300 | The match is done against the plain internet media type string without 301 | additional parameters like ``charset=utf-8`` or the like. 302 | 303 | .. seealso:: 304 | 305 | `WebOb documentation: Return the content type, but leaving off any parameters `_ 306 | 307 | 308 | Error responses 309 | ~~~~~~~~~~~~~~~ 310 | 311 | When requests are rejected, an appropriate error response 312 | is sent to the client using the configured `error_handler`. 313 | To give the service consumer a hint about the valid internet 314 | media types to use for the ``Content-Type`` header, 315 | the error response contains a list of allowed types. 316 | 317 | When using the default json `error_handler`, the response might look like this:: 318 | 319 | { 320 | 'status': 'error', 321 | 'errors': [ 322 | { 323 | 'location': 'header', 324 | 'name': 'Content-Type', 325 | 'description': 'Content-Type header should be one of ["text/xml", "application/json"]' 326 | } 327 | ] 328 | } 329 | 330 | 331 | Managing ACLs 332 | ============= 333 | 334 | You can also specify a way to deal with ACLs: have your factory 335 | return an ACL, and that ACL will be applied to all views 336 | in the service: 337 | 338 | .. code-block:: python 339 | 340 | class MyFactory(object): 341 | 342 | def __init__(self, request, context=None): 343 | self.request = request 344 | 345 | def __acl__(self): 346 | return [ 347 | (Allow, Everyone, 'view'), 348 | (Allow, 'group:editors', 'edit') 349 | ] 350 | 351 | foo = Service(name='foo', path='/foo', factory=MyFactory) 352 | 353 | 354 | Filters 355 | ======= 356 | 357 | Cornice can also filter the response returned by your views. This can be 358 | useful if you want to add some behaviour once a response has been issued. 359 | 360 | Here is how to define a validator for a service: 361 | 362 | .. code-block:: python 363 | 364 | foo = Service(name='foo', path='/foo', filters=your_callable) 365 | 366 | You can just add the filter for a specific method: 367 | 368 | .. code-block:: python 369 | 370 | @foo.get(filters=your_callable) 371 | def foo_get(request): 372 | """some description of the validator for documentation reasons""" 373 | pass 374 | 375 | In case you would like to register a filter for all the services but one, you 376 | can use the `exclude` parameter. It works either on services or on methods: 377 | 378 | .. code-block:: python 379 | 380 | @foo.get(exclude=your_callable) 381 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | dynamic = ["version", "dependencies", "readme"] 3 | name = "cornice" 4 | description = "Define Web Services in Pyramid." 5 | license = {file = "LICENSE"} 6 | classifiers = [ 7 | "Programming Language :: Python", 8 | "Programming Language :: Python :: 3", 9 | "Programming Language :: Python :: Implementation :: CPython", 10 | "Topic :: Internet :: WWW/HTTP", 11 | "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", 12 | "Framework :: Pylons", 13 | "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", 14 | ] 15 | keywords = ["web pyramid pylons"] 16 | authors = [ 17 | {name = "Mozilla Services", email = "services-dev@mozilla.org"}, 18 | ] 19 | 20 | [project.urls] 21 | Repository = "https://github.com/Cornices/cornice" 22 | 23 | [tool.setuptools_scm] 24 | # can be empty if no extra settings are needed, presence enables setuptools_scm 25 | 26 | [tool.setuptools.dynamic] 27 | dependencies = { file = ["requirements.in"] } 28 | readme = {file = ["README.rst", "CONTRIBUTORS.txt"]} 29 | 30 | [build-system] 31 | requires = ["setuptools>=64", "setuptools_scm>=8"] 32 | build-backend = "setuptools.build_meta" 33 | 34 | [project.optional-dependencies] 35 | dev = [ 36 | "ruff", 37 | "pytest<9", 38 | "pytest-cache<2", 39 | "pytest-cov<7", 40 | "WebTest<4", 41 | "marshmallow<4", 42 | "colander<3", 43 | ] 44 | 45 | [tool.pip-tools] 46 | generate-hashes = true 47 | 48 | [tool.coverage.run] 49 | relative_files = true 50 | 51 | [tool.ruff] 52 | line-length = 99 53 | extend-exclude = [ 54 | "__pycache__", 55 | ".venv/", 56 | ] 57 | 58 | [tool.ruff.lint] 59 | select = [ 60 | # pycodestyle 61 | "E", "W", 62 | # flake8 63 | "F", 64 | # isort 65 | "I", 66 | ] 67 | ignore = [ 68 | # `format` will wrap lines. 69 | "E501", 70 | ] 71 | 72 | [tool.ruff.lint.isort] 73 | lines-after-imports = 2 74 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | pyramid>=1.7,<3 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.9 3 | # by the following command: 4 | # 5 | # pip-compile --allow-unsafe --generate-hashes 6 | # 7 | hupper==1.12.1 \ 8 | --hash=sha256:06bf54170ff4ecf4c84ad5f188dee3901173ab449c2608ad05b9bfd6b13e32eb \ 9 | --hash=sha256:e872b959f09d90be5fb615bd2e62de89a0b57efc037bdf9637fb09cdf8552b19 10 | # via pyramid 11 | pastedeploy==3.1.0 \ 12 | --hash=sha256:76388ad53a661448d436df28c798063108f70e994ddc749540d733cdbd1b38cf \ 13 | --hash=sha256:9ddbaf152f8095438a9fe81f82c78a6714b92ae8e066bed418b6a7ff6a095a95 14 | # via plaster-pastedeploy 15 | plaster==1.1.2 \ 16 | --hash=sha256:42992ab1f4865f1278e2ad740e8ad145683bb4022e03534265528f0c23c0df2d \ 17 | --hash=sha256:f8befc54bf8c1147c10ab40297ec84c2676fa2d4ea5d6f524d9436a80074ef98 18 | # via 19 | # plaster-pastedeploy 20 | # pyramid 21 | plaster-pastedeploy==1.0.1 \ 22 | --hash=sha256:ad3550cc744648969ed3b810f33c9344f515ee8d8a8cec18e8f2c4a643c2181f \ 23 | --hash=sha256:be262e6d2e41a7264875daa2fe2850cbb0615728bcdc92828fdc72736e381412 24 | # via pyramid 25 | pyramid==2.0.2 \ 26 | --hash=sha256:2e6585ac55c147f0a51bc00dadf72075b3bdd9a871b332ff9e5e04117ccd76fa \ 27 | --hash=sha256:372138a738e4216535cc76dcce6eddd5a1aaca95130f2354fb834264c06f18de 28 | # via -r requirements.in 29 | translationstring==1.4 \ 30 | --hash=sha256:5f4dc4d939573db851c8d840551e1a0fb27b946afe3b95aafc22577eed2d6262 \ 31 | --hash=sha256:bf947538d76e69ba12ab17283b10355a9ecfbc078e6123443f43f2107f6376f3 32 | # via pyramid 33 | venusian==3.1.0 \ 34 | --hash=sha256:d1fb1e49927f42573f6c9b7c4fcf61c892af8fdcaa2314daa01d9a560b23488d \ 35 | --hash=sha256:eb72cdca6f3139a15dc80f9c95d3c10f8a54a0ba881eeef8e2ec5b42d3ee3a95 36 | # via pyramid 37 | webob==1.8.8 \ 38 | --hash=sha256:2abc1555e118fc251e705fc6dc66c7f5353bb9fbfab6d20e22f1c02b4b71bcee \ 39 | --hash=sha256:b60ba63f05c0cf61e086a10c3781a41fcfe30027753a8ae6d819c77592ce83ea 40 | # via pyramid 41 | zope-deprecation==5.0 \ 42 | --hash=sha256:28c2ee983812efb4676d33c7a8c6ade0df191c1c6d652bbbfe6e2eeee067b2d4 \ 43 | --hash=sha256:b7c32d3392036b2145c40b3103e7322db68662ab09b7267afe1532a9d93f640f 44 | # via pyramid 45 | zope-interface==6.1 \ 46 | --hash=sha256:0c8cf55261e15590065039696607f6c9c1aeda700ceee40c70478552d323b3ff \ 47 | --hash=sha256:13b7d0f2a67eb83c385880489dbb80145e9d344427b4262c49fbf2581677c11c \ 48 | --hash=sha256:1f294a15f7723fc0d3b40701ca9b446133ec713eafc1cc6afa7b3d98666ee1ac \ 49 | --hash=sha256:239a4a08525c080ff833560171d23b249f7f4d17fcbf9316ef4159f44997616f \ 50 | --hash=sha256:2f8d89721834524a813f37fa174bac074ec3d179858e4ad1b7efd4401f8ac45d \ 51 | --hash=sha256:2fdc7ccbd6eb6b7df5353012fbed6c3c5d04ceaca0038f75e601060e95345309 \ 52 | --hash=sha256:34c15ca9248f2e095ef2e93af2d633358c5f048c49fbfddf5fdfc47d5e263736 \ 53 | --hash=sha256:387545206c56b0315fbadb0431d5129c797f92dc59e276b3ce82db07ac1c6179 \ 54 | --hash=sha256:43b576c34ef0c1f5a4981163b551a8781896f2a37f71b8655fd20b5af0386abb \ 55 | --hash=sha256:57d0a8ce40ce440f96a2c77824ee94bf0d0925e6089df7366c2272ccefcb7941 \ 56 | --hash=sha256:5a804abc126b33824a44a7aa94f06cd211a18bbf31898ba04bd0924fbe9d282d \ 57 | --hash=sha256:67be3ca75012c6e9b109860820a8b6c9a84bfb036fbd1076246b98e56951ca92 \ 58 | --hash=sha256:6af47f10cfc54c2ba2d825220f180cc1e2d4914d783d6fc0cd93d43d7bc1c78b \ 59 | --hash=sha256:6dc998f6de015723196a904045e5a2217f3590b62ea31990672e31fbc5370b41 \ 60 | --hash=sha256:70d2cef1bf529bff41559be2de9d44d47b002f65e17f43c73ddefc92f32bf00f \ 61 | --hash=sha256:7ebc4d34e7620c4f0da7bf162c81978fce0ea820e4fa1e8fc40ee763839805f3 \ 62 | --hash=sha256:964a7af27379ff4357dad1256d9f215047e70e93009e532d36dcb8909036033d \ 63 | --hash=sha256:97806e9ca3651588c1baaebb8d0c5ee3db95430b612db354c199b57378312ee8 \ 64 | --hash=sha256:9b9bc671626281f6045ad61d93a60f52fd5e8209b1610972cf0ef1bbe6d808e3 \ 65 | --hash=sha256:9ffdaa5290422ac0f1688cb8adb1b94ca56cee3ad11f29f2ae301df8aecba7d1 \ 66 | --hash=sha256:a0da79117952a9a41253696ed3e8b560a425197d4e41634a23b1507efe3273f1 \ 67 | --hash=sha256:a41f87bb93b8048fe866fa9e3d0c51e27fe55149035dcf5f43da4b56732c0a40 \ 68 | --hash=sha256:aa6fd016e9644406d0a61313e50348c706e911dca29736a3266fc9e28ec4ca6d \ 69 | --hash=sha256:ad54ed57bdfa3254d23ae04a4b1ce405954969c1b0550cc2d1d2990e8b439de1 \ 70 | --hash=sha256:b012d023b4fb59183909b45d7f97fb493ef7a46d2838a5e716e3155081894605 \ 71 | --hash=sha256:b51b64432eed4c0744241e9ce5c70dcfecac866dff720e746d0a9c82f371dfa7 \ 72 | --hash=sha256:bbe81def9cf3e46f16ce01d9bfd8bea595e06505e51b7baf45115c77352675fd \ 73 | --hash=sha256:c9559138690e1bd4ea6cd0954d22d1e9251e8025ce9ede5d0af0ceae4a401e43 \ 74 | --hash=sha256:e30506bcb03de8983f78884807e4fd95d8db6e65b69257eea05d13d519b83ac0 \ 75 | --hash=sha256:e33e86fd65f369f10608b08729c8f1c92ec7e0e485964670b4d2633a4812d36b \ 76 | --hash=sha256:e441e8b7d587af0414d25e8d05e27040d78581388eed4c54c30c0c91aad3a379 \ 77 | --hash=sha256:e8bb9c990ca9027b4214fa543fd4025818dc95f8b7abce79d61dc8a2112b561a \ 78 | --hash=sha256:ef43ee91c193f827e49599e824385ec7c7f3cd152d74cb1dfe02cb135f264d83 \ 79 | --hash=sha256:ef467d86d3cfde8b39ea1b35090208b0447caaabd38405420830f7fd85fbdd56 \ 80 | --hash=sha256:f89b28772fc2562ed9ad871c865f5320ef761a7fcc188a935e21fe8b31a38ca9 \ 81 | --hash=sha256:fddbab55a2473f1d3b8833ec6b7ac31e8211b0aa608df5ab09ce07f3727326de 82 | # via pyramid 83 | 84 | # The following packages are considered to be unsafe in a requirements file: 85 | setuptools==77.0.3 \ 86 | --hash=sha256:583b361c8da8de57403743e756609670de6fb2345920e36dc5c2d914c319c945 \ 87 | --hash=sha256:67122e78221da5cf550ddd04cf8742c8fe12094483749a792d56cd669d6cf58c 88 | # via 89 | # pyramid 90 | # zope-deprecation 91 | # zope-interface 92 | -------------------------------------------------------------------------------- /src/cornice/__init__.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 | # You can obtain one at http://mozilla.org/MPL/2.0/. 4 | import logging 5 | from functools import partial 6 | 7 | import pkg_resources 8 | from pyramid.events import NewRequest 9 | from pyramid.httpexceptions import HTTPForbidden, HTTPNotFound 10 | from pyramid.security import NO_PERMISSION_REQUIRED 11 | from pyramid.settings import asbool, aslist 12 | 13 | from cornice.errors import Errors # NOQA 14 | from cornice.pyramidhook import ( 15 | handle_exceptions, 16 | register_resource_views, 17 | register_service_views, 18 | wrap_request, 19 | ) 20 | from cornice.renderer import CorniceRenderer 21 | from cornice.service import Service # NOQA 22 | from cornice.util import ContentTypePredicate, current_service 23 | 24 | 25 | logger = logging.getLogger("cornice") 26 | # Module version, as defined in PEP-0396. 27 | __version__ = pkg_resources.get_distribution(__package__).version 28 | 29 | 30 | def set_localizer_for_languages(event, available_languages, default_locale_name): 31 | """ 32 | Sets the current locale based on the incoming Accept-Language header, if 33 | present, and sets a localizer attribute on the request object based on 34 | the current locale. 35 | 36 | To be used as an event handler, this function needs to be partially applied 37 | with the available_languages and default_locale_name arguments. The 38 | resulting function will be an event handler which takes an event object as 39 | its only argument. 40 | """ 41 | request = event.request 42 | if request.accept_language: 43 | accepted = request.accept_language.lookup(available_languages, default=default_locale_name) 44 | request._LOCALE_ = accepted 45 | 46 | 47 | def setup_localization(config): 48 | """ 49 | Setup localization based on the available_languages and 50 | pyramid.default_locale_name settings. 51 | 52 | These settings are named after suggestions from the "Internationalization 53 | and Localization" section of the Pyramid documentation. 54 | """ 55 | try: 56 | config.add_translation_dirs("colander:locale/") 57 | settings = config.get_settings() 58 | available_languages = aslist(settings["available_languages"]) 59 | default_locale_name = settings.get("pyramid.default_locale_name", "en") 60 | set_localizer = partial( 61 | set_localizer_for_languages, 62 | available_languages=available_languages, 63 | default_locale_name=default_locale_name, 64 | ) 65 | config.add_subscriber(set_localizer, NewRequest) 66 | except ImportError: # pragma: no cover 67 | # add_translation_dirs raises an ImportError if colander is not 68 | # installed 69 | pass 70 | 71 | 72 | def includeme(config): 73 | """Include the Cornice definitions""" 74 | # attributes required to maintain services 75 | config.registry.cornice_services = {} 76 | 77 | settings = config.get_settings() 78 | 79 | # localization request subscriber must be set before first call 80 | # for request.localizer (in wrap_request) 81 | if settings.get("available_languages"): 82 | setup_localization(config) 83 | 84 | config.add_directive("add_cornice_service", register_service_views) 85 | config.add_directive("add_cornice_resource", register_resource_views) 86 | config.add_subscriber(wrap_request, NewRequest) 87 | config.add_renderer("cornicejson", CorniceRenderer()) 88 | config.add_view_predicate("content_type", ContentTypePredicate) 89 | config.add_request_method(current_service, reify=True) 90 | 91 | if asbool(settings.get("handle_exceptions", True)): 92 | config.add_view(handle_exceptions, context=Exception, permission=NO_PERMISSION_REQUIRED) 93 | config.add_view(handle_exceptions, context=HTTPNotFound, permission=NO_PERMISSION_REQUIRED) 94 | config.add_view( 95 | handle_exceptions, context=HTTPForbidden, permission=NO_PERMISSION_REQUIRED 96 | ) 97 | -------------------------------------------------------------------------------- /src/cornice/cors.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 | # You can obtain one at http://mozilla.org/MPL/2.0/. 4 | import fnmatch 5 | import functools 6 | 7 | from pyramid.settings import asbool 8 | 9 | 10 | CORS_PARAMETERS = ( 11 | "cors_headers", 12 | "cors_enabled", 13 | "cors_origins", 14 | "cors_credentials", 15 | "cors_max_age", 16 | "cors_expose_all_headers", 17 | ) 18 | 19 | 20 | def get_cors_preflight_view(service): 21 | """Return a view for the OPTION method. 22 | 23 | Checks that the User-Agent is authorized to do a request to the server, and 24 | to this particular service, and add the various checks that are specified 25 | in http://www.w3.org/TR/cors/#resource-processing-model. 26 | """ 27 | 28 | def _preflight_view(request): 29 | response = request.response 30 | origin = request.headers.get("Origin") 31 | supported_headers = service.cors_supported_headers_for() 32 | 33 | if not origin: 34 | request.errors.add("header", "Origin", "this header is mandatory") 35 | 36 | requested_method = request.headers.get("Access-Control-Request-Method") 37 | if not requested_method: 38 | request.errors.add( 39 | "header", "Access-Control-Request-Method", "this header is mandatory" 40 | ) 41 | 42 | if not (requested_method and origin): 43 | return 44 | 45 | requested_headers = request.headers.get("Access-Control-Request-Headers", ()) 46 | 47 | if requested_headers: 48 | requested_headers = map(str.strip, requested_headers.split(",")) 49 | 50 | if requested_method not in service.cors_supported_methods: 51 | request.errors.add("header", "Access-Control-Request-Method", "Method not allowed") 52 | 53 | if not service.cors_expose_all_headers: 54 | for h in requested_headers: 55 | if h.lower() not in [s.lower() for s in supported_headers]: 56 | request.errors.add( 57 | "header", "Access-Control-Request-Headers", 'Header "%s" not allowed' % h 58 | ) 59 | 60 | supported_headers = set(supported_headers) | set(requested_headers) 61 | 62 | response.headers["Access-Control-Allow-Headers"] = ",".join(supported_headers) 63 | 64 | response.headers["Access-Control-Allow-Methods"] = ",".join(service.cors_supported_methods) 65 | 66 | max_age = service.cors_max_age_for(requested_method) 67 | if max_age is not None: 68 | response.headers["Access-Control-Max-Age"] = str(max_age) 69 | 70 | return None 71 | 72 | return _preflight_view 73 | 74 | 75 | def _get_method(request): 76 | """Return what's supposed to be the method for CORS operations. 77 | (e.g if the verb is options, look at the A-C-Request-Method header, 78 | otherwise return the HTTP verb). 79 | """ 80 | if request.method == "OPTIONS": 81 | method = request.headers.get("Access-Control-Request-Method", request.method) 82 | else: 83 | method = request.method 84 | return method 85 | 86 | 87 | def ensure_origin(service, request, response=None, **kwargs): 88 | """Ensure that the origin header is set and allowed.""" 89 | response = response or request.response 90 | 91 | # Don't check this twice. 92 | if not request.info.get("cors_checked", False): 93 | method = _get_method(request) 94 | 95 | origin = request.headers.get("Origin") 96 | 97 | if not origin: 98 | always_cors = asbool(request.registry.settings.get("cornice.always_cors")) 99 | # With this setting, if the service origins has "*", then 100 | # always return CORS headers. 101 | origins = getattr(service, "cors_origins", []) 102 | if always_cors and "*" in origins: 103 | origin = "*" 104 | 105 | if origin: 106 | if not any([fnmatch.fnmatchcase(origin, o) for o in service.cors_origins_for(method)]): 107 | request.errors.add("header", "Origin", "%s not allowed" % origin) 108 | elif service.cors_support_credentials_for(method): 109 | response.headers["Access-Control-Allow-Origin"] = origin 110 | else: 111 | if any([o == "*" for o in service.cors_origins_for(method)]): 112 | response.headers["Access-Control-Allow-Origin"] = "*" 113 | else: 114 | response.headers["Access-Control-Allow-Origin"] = origin 115 | request.info["cors_checked"] = True 116 | return response 117 | 118 | 119 | def get_cors_validator(service): 120 | return functools.partial(ensure_origin, service) 121 | 122 | 123 | def apply_cors_post_request(service, request, response): 124 | """Handles CORS-related post-request things. 125 | 126 | Add some response headers, such as the Expose-Headers and the 127 | Allow-Credentials ones. 128 | """ 129 | response = ensure_origin(service, request, response) 130 | method = _get_method(request) 131 | 132 | if ( 133 | service.cors_support_credentials_for(method) 134 | and "Access-Control-Allow-Credentials" not in response.headers 135 | ): 136 | response.headers["Access-Control-Allow-Credentials"] = "true" 137 | 138 | if request.method != "OPTIONS": 139 | # Which headers are exposed? 140 | supported_headers = service.cors_supported_headers_for(request.method) 141 | if supported_headers: 142 | response.headers["Access-Control-Expose-Headers"] = ", ".join(supported_headers) 143 | 144 | return response 145 | -------------------------------------------------------------------------------- /src/cornice/errors.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 | # You can obtain one at http://mozilla.org/MPL/2.0/. 4 | import json 5 | 6 | from pyramid.i18n import TranslationString 7 | 8 | 9 | class Errors(list): 10 | """Holds Request errors""" 11 | 12 | def __init__(self, status=400, localizer=None): 13 | self.status = status 14 | self.localizer = localizer 15 | super(Errors, self).__init__() 16 | 17 | def add(self, location, name=None, description=None, **kw): 18 | """Registers a new error.""" 19 | allowed = ("body", "querystring", "url", "header", "path", "cookies", "method") 20 | if location != "" and location not in allowed: 21 | raise ValueError("%r not in %s" % (location, allowed)) 22 | 23 | if isinstance(description, TranslationString) and self.localizer: 24 | description = self.localizer.translate(description) 25 | 26 | self.append(dict(location=location, name=name, description=description, **kw)) 27 | 28 | @classmethod 29 | def from_json(cls, string): 30 | """Transforms a json string into an `Errors` instance""" 31 | obj = json.loads(string.decode()) 32 | return Errors.from_list(obj.get("errors", [])) 33 | 34 | @classmethod 35 | def from_list(cls, obj): 36 | """Transforms a python list into an `Errors` instance""" 37 | errors = Errors() 38 | for error in obj: 39 | errors.add(**error) 40 | return errors 41 | -------------------------------------------------------------------------------- /src/cornice/pyramidhook.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 | # You can obtain one at http://mozilla.org/MPL/2.0/. 4 | import copy 5 | import functools 6 | import itertools 7 | 8 | from pyramid.exceptions import PredicateMismatch 9 | from pyramid.httpexceptions import ( 10 | HTTPException, 11 | HTTPMethodNotAllowed, 12 | HTTPNotAcceptable, 13 | HTTPUnsupportedMediaType, 14 | ) 15 | from pyramid.security import NO_PERMISSION_REQUIRED 16 | 17 | from cornice.cors import ( 18 | CORS_PARAMETERS, 19 | apply_cors_post_request, 20 | get_cors_preflight_view, 21 | get_cors_validator, 22 | ) 23 | from cornice.errors import Errors 24 | from cornice.service import decorate_view 25 | from cornice.util import ( 26 | content_type_matches, 27 | current_service, 28 | is_string, 29 | match_accept_header, 30 | match_content_type_header, 31 | to_list, 32 | ) 33 | 34 | 35 | def get_fallback_view(service): 36 | """Fallback view for a given service, called when nothing else matches. 37 | 38 | This method provides the view logic to be executed when the request 39 | does not match any explicitly-defined view. Its main responsibility 40 | is to produce an accurate error response, such as HTTPMethodNotAllowed, 41 | HTTPNotAcceptable or HTTPUnsupportedMediaType. 42 | """ 43 | 44 | def _fallback_view(request): 45 | # Maybe we failed to match any definitions for the request method? 46 | if request.method not in service.defined_methods: 47 | response = HTTPMethodNotAllowed() 48 | response.allow = service.defined_methods 49 | raise response 50 | # Maybe we failed to match an acceptable content-type? 51 | # First search all the definitions to find the acceptable types. 52 | # XXX: precalculate this like the defined_methods list? 53 | acceptable = [] 54 | supported_contenttypes = [] 55 | for method, _, args in service.definitions: 56 | if method != request.method: 57 | continue 58 | 59 | if "accept" in args: 60 | acceptable.extend(service.get_acceptable(method, filter_callables=True)) 61 | acceptable.extend(request.info.get("acceptable", [])) 62 | acceptable = list(set(acceptable)) 63 | 64 | # Now check if that was actually the source of the problem. 65 | if not request.accept.acceptable_offers(offers=acceptable): 66 | request.errors.add( 67 | "header", 68 | "Accept", 69 | "Accept header should be one of {0}".format(acceptable).encode("ascii"), 70 | ) 71 | request.errors.status = HTTPNotAcceptable.code 72 | error = service.error_handler(request) 73 | raise error 74 | 75 | if "content_type" in args: 76 | supported_contenttypes.extend( 77 | service.get_contenttypes(method, filter_callables=True) 78 | ) 79 | supported_contenttypes.extend(request.info.get("supported_contenttypes", [])) 80 | supported_contenttypes = list(set(supported_contenttypes)) 81 | 82 | # Now check if that was actually the source of the problem. 83 | if not content_type_matches(request, supported_contenttypes): 84 | request.errors.add( 85 | "header", 86 | "Content-Type", 87 | "Content-Type header should be one of {0}".format( 88 | supported_contenttypes 89 | ).encode("ascii"), 90 | ) 91 | request.errors.status = HTTPUnsupportedMediaType.code 92 | error = service.error_handler(request) 93 | raise error 94 | 95 | # In the absence of further information about what went wrong, 96 | # let upstream deal with the mismatch. 97 | 98 | # After "custom predicates" feature has been added there is no need in 99 | # this line. Instead requests will be filtered by "custom predicates" 100 | # feature filter and exception "404 Not found" error will be raised. In 101 | # order to avoid unpredictable cases, we left this line in place and 102 | # excluded it from coverage. 103 | raise PredicateMismatch(service.name) # pragma: no cover 104 | 105 | return _fallback_view 106 | 107 | 108 | def apply_filters(request, response): 109 | if request.matched_route is not None: 110 | # do some sanity checking on the response using filters 111 | service = current_service(request) 112 | if service is not None: 113 | kwargs, ob = getattr(request, "cornice_args", ({}, None)) 114 | for _filter in kwargs.get("filters", []): 115 | if is_string(_filter) and ob is not None: 116 | _filter = getattr(ob, _filter) 117 | try: 118 | response = _filter(response, request) 119 | except TypeError: 120 | response = _filter(response) 121 | if service.cors_enabled: 122 | apply_cors_post_request(service, request, response) 123 | 124 | return response 125 | 126 | 127 | def handle_exceptions(exc, request): 128 | # At this stage, the checks done by the validators had been removed because 129 | # a new response started (the exception), so we need to do that again. 130 | if not isinstance(exc, HTTPException): 131 | raise 132 | request.info["cors_checked"] = False 133 | return apply_filters(request, exc) 134 | 135 | 136 | def add_nosniff_header(request, response): 137 | """IE has some rather unfortunately content-type-sniffing behaviour 138 | that can be used to trigger XSS attacks via a JSON API, as described here: 139 | 140 | * http://blog.watchfire.com/wfblog/2011/10/json-based-xss-exploitation.html 141 | * https://superevr.com/blog/2012/exploiting-xss-in-ajax-web-applications/ 142 | 143 | Make cornice safe-by-default against this attack by including the header. 144 | """ 145 | response.headers.setdefault("X-Content-Type-Options", "nosniff") 146 | 147 | 148 | def wrap_request(event): 149 | """Adds a "validated" dict, a custom "errors" object and an "info" dict to 150 | the request object if they don't already exists 151 | """ 152 | request = event.request 153 | request.add_response_callback(apply_filters) 154 | request.add_response_callback(add_nosniff_header) 155 | 156 | if not hasattr(request, "validated"): 157 | setattr(request, "validated", {}) 158 | 159 | if not hasattr(request, "errors"): 160 | if request.registry.settings.get("available_languages"): 161 | setattr(request, "errors", Errors(localizer=request.localizer)) 162 | else: 163 | setattr(request, "errors", Errors()) 164 | 165 | if not hasattr(request, "info"): 166 | setattr(request, "info", {}) 167 | 168 | 169 | def register_service_views(config, service): 170 | """Register the routes of the given service into the pyramid router. 171 | 172 | :param config: the pyramid configuration object that will be populated. 173 | :param service: the service object containing the definitions 174 | """ 175 | route_name = service.name 176 | existing_route = service.pyramid_route 177 | prefix = config.route_prefix or "" 178 | services = config.registry.cornice_services 179 | if existing_route: 180 | route_name = existing_route 181 | services["__cornice" + existing_route] = service 182 | else: 183 | services[prefix + service.path] = service 184 | 185 | # before doing anything else, register a view for the OPTIONS method 186 | # if we need to 187 | if service.cors_enabled and "OPTIONS" not in service.defined_methods: 188 | service.add_view( 189 | "options", view=get_cors_preflight_view(service), permission=NO_PERMISSION_REQUIRED 190 | ) 191 | 192 | # register the fallback view, which takes care of returning good error 193 | # messages to the user-agent 194 | cors_validator = get_cors_validator(service) 195 | 196 | # Cornice-specific arguments that pyramid does not know about 197 | cornice_parameters = ( 198 | "filters", 199 | "validators", 200 | "schema", 201 | "klass", 202 | "error_handler", 203 | "deserializer", 204 | ) + CORS_PARAMETERS 205 | 206 | # 1. register route 207 | 208 | route_args = {} 209 | 210 | if hasattr(service, "factory"): 211 | route_args["factory"] = service.factory 212 | 213 | routes = config.get_predlist("route") 214 | for predicate in routes.sorter.names: 215 | # Do not let the custom predicates handle validation of Header Accept, 216 | # which will pass it through to pyramid. It is handled by 217 | # _fallback_view(), because it allows callable. 218 | if predicate == "accept": 219 | continue 220 | 221 | if hasattr(service, predicate): 222 | route_args[predicate] = getattr(service, predicate) 223 | 224 | # register route when not using exiting pyramid routes 225 | if not existing_route: 226 | config.add_route(route_name, service.path, **route_args) 227 | 228 | # 2. register view(s) 229 | 230 | for method, view, args in service.definitions: 231 | args = copy.copy(args) # make a copy of the dict to not modify it 232 | # Deepcopy only the params we're possibly passing on to pyramid 233 | # (Some of those in cornice_parameters, e.g. ``schema``, may contain 234 | # unpickleable values.) 235 | for item in args: 236 | if item not in cornice_parameters: 237 | args[item] = copy.deepcopy(args[item]) 238 | 239 | args["request_method"] = method 240 | 241 | if service.cors_enabled: 242 | args["validators"].insert(0, cors_validator) 243 | 244 | decorated_view = decorate_view(view, dict(args), method, route_args) 245 | 246 | for item in cornice_parameters: 247 | if item in args: 248 | del args[item] 249 | 250 | # filter predicates defined on Resource 251 | route_predicates = config.get_predlist("route").sorter.names 252 | view_predicates = config.get_predlist("view").sorter.names 253 | for pred in set(route_predicates).difference(view_predicates): 254 | if pred in args: 255 | args.pop(pred) 256 | 257 | # pop and compute predicates which get passed through to Pyramid 1:1 258 | 259 | predicate_definitions = _pop_complex_predicates(args) 260 | 261 | if predicate_definitions: 262 | empty_contenttype = [({"kind": "content_type", "value": ""},)] 263 | for predicate_list in predicate_definitions + empty_contenttype: 264 | args = dict(args) # make a copy of the dict to not modify it 265 | 266 | # prepare view args by evaluating complex predicates 267 | _mungle_view_args(args, predicate_list) 268 | 269 | # We register the same view multiple times with different 270 | # accept / content_type / custom_predicates arguments 271 | config.add_view(view=decorated_view, route_name=route_name, **args) 272 | 273 | else: 274 | # it is a simple view, we don't need to loop on the definitions 275 | # and just add it one time. 276 | config.add_view(view=decorated_view, route_name=route_name, **args) 277 | 278 | if service.definitions: 279 | # Add the fallback view last 280 | config.add_view( 281 | view=get_fallback_view(service), 282 | route_name=route_name, 283 | permission=NO_PERMISSION_REQUIRED, 284 | require_csrf=False, 285 | ) 286 | 287 | 288 | def _pop_complex_predicates(args): 289 | """ 290 | Compute the cartesian product of "accept" and "content_type" 291 | fields to establish all possible predicate combinations. 292 | 293 | .. seealso:: 294 | 295 | https://github.com/mozilla-services/cornice/pull/91#discussion_r3441384 296 | """ 297 | 298 | # pop and prepare individual predicate lists 299 | accept_list = _pop_predicate_definition(args, "accept") 300 | content_type_list = _pop_predicate_definition(args, "content_type") 301 | 302 | # compute cartesian product of prepared lists, additionally 303 | # remove empty elements of input and output lists 304 | product_input = filter(None, [accept_list, content_type_list]) 305 | 306 | # In Python 3, the filter() function returns an iterator, not a list. 307 | # http://getpython3.com/diveintopython3/ \ 308 | # porting-code-to-python-3-with-2to3.html#filter 309 | predicate_product = list(filter(None, itertools.product(*product_input))) 310 | 311 | return predicate_product 312 | 313 | 314 | def _pop_predicate_definition(args, kind): 315 | """ 316 | Build a dictionary enriched by "kind" of predicate definition list. 317 | This is required for evaluation in ``_mungle_view_args``. 318 | """ 319 | values = to_list(args.pop(kind, ())) 320 | # In much the same way as filter(), the map() function [in Python 3] now 321 | # returns an iterator. (In Python 2, it returned a list.) 322 | # http://getpython3.com/diveintopython3/ \ 323 | # porting-code-to-python-3-with-2to3.html#map 324 | values = list(map(lambda value: {"kind": kind, "value": value}, values)) 325 | return values 326 | 327 | 328 | def _mungle_view_args(args, predicate_list): 329 | """ 330 | Prepare view args by evaluating complex predicates 331 | which get passed through to Pyramid 1:1. 332 | Also resolve predicate definitions passed as callables. 333 | 334 | .. seealso:: 335 | 336 | https://github.com/mozilla-services/cornice/pull/91#discussion_r3441384 337 | """ 338 | 339 | # map kind of argument value to function for resolving callables 340 | callable_map = { 341 | "accept": match_accept_header, 342 | "content_type": match_content_type_header, 343 | } 344 | 345 | # iterate and resolve all predicates 346 | for predicate_entry in predicate_list: 347 | kind = predicate_entry["kind"] 348 | value = predicate_entry["value"] 349 | 350 | # we need to build a custom predicate if argument value is a callable 351 | predicates = args.get("custom_predicates", []) 352 | if callable(value): 353 | func = callable_map[kind] 354 | predicate_checker = functools.partial(func, value) 355 | predicates.append(predicate_checker) 356 | args["custom_predicates"] = predicates 357 | else: 358 | # otherwise argument value is just a scalar 359 | args[kind] = value 360 | 361 | 362 | def register_resource_views(config, resource): 363 | """Register a resource and it's views. 364 | 365 | :param config: 366 | The pyramid configuration object that will be populated. 367 | :param resource: 368 | The resource class containing the definitions 369 | """ 370 | services = resource._services 371 | 372 | for service in services.values(): 373 | config.add_cornice_service(service) 374 | -------------------------------------------------------------------------------- /src/cornice/renderer.py: -------------------------------------------------------------------------------- 1 | from pyramid import httpexceptions as exc 2 | from pyramid.renderers import JSON 3 | from pyramid.response import Response 4 | 5 | 6 | def bytes_adapter(obj, request): 7 | """Convert bytes objects to strings for json error renderer.""" 8 | if isinstance(obj, bytes): 9 | return obj.decode("utf8") 10 | return obj 11 | 12 | 13 | class JSONError(exc.HTTPError): 14 | def __init__(self, serializer, serializer_kw, errors, status=400): 15 | body = {"status": "error", "errors": errors} 16 | Response.__init__(self, serializer(body, **serializer_kw)) 17 | self.status = status 18 | self.content_type = "application/json" 19 | 20 | 21 | class CorniceRenderer(JSON): 22 | """We implement JSON serialization by extending Pyramid's default 23 | JSON rendering machinery using our own custom Content-Type logic `[1]`_. 24 | 25 | This allows developers to config the JSON renderer using Pyramid's 26 | configuration machinery `[2]`_. 27 | 28 | .. _`[1]`: https://github.com/mozilla-services/cornice/pull/116 \ 29 | #issuecomment-14355865 30 | .. _`[2]`: http://pyramid.readthedocs.io/en/latest/narr/renderers.html \ 31 | #serializing-custom-objects 32 | """ 33 | 34 | acceptable = ("application/json", "text/plain") 35 | 36 | def __init__(self, *args, **kwargs): 37 | """Adds a `bytes` adapter by default.""" 38 | super(CorniceRenderer, self).__init__(*args, **kwargs) 39 | self.add_adapter(bytes, bytes_adapter) 40 | 41 | def render_errors(self, request): 42 | """Returns an HTTPError with the given status and message. 43 | 44 | The HTTP error content type is "application/json" 45 | """ 46 | default = self._make_default(request) 47 | serializer_kw = self.kw.copy() 48 | serializer_kw["default"] = default 49 | return JSONError( 50 | serializer=self.serializer, 51 | serializer_kw=serializer_kw, 52 | errors=request.errors, 53 | status=request.errors.status, 54 | ) 55 | 56 | def render(self, value, system): 57 | """Extends the default `_render` function of the pyramid JSON renderer. 58 | 59 | Compared to the default `pyramid.renderers.JSON` renderer: 60 | 1. Overrides the response with an empty string and 61 | no Content-Type in case of HTTP 204. 62 | 2. Overrides the default behavior of Content-Type handling, 63 | forcing the use of `acceptable_offers`, instead of letting 64 | the user specify the Content-Type manually. 65 | TODO: maybe explain this a little better 66 | """ 67 | request = system.get("request") 68 | if request is not None: 69 | response = request.response 70 | 71 | # Do not return content with ``204 No Content`` 72 | if response.status_code == 204: 73 | response.content_type = None 74 | return "" 75 | 76 | ctypes = request.accept.acceptable_offers(offers=self.acceptable) 77 | if not ctypes: 78 | ctypes = [(self.acceptable[0], 1.0)] 79 | response.content_type = ctypes[0][0] 80 | default = self._make_default(request) 81 | return self.serializer(value, default=default, **self.kw) 82 | 83 | def __call__(self, info): 84 | """Overrides the default behavior of `pyramid.renderers.JSON`. 85 | 86 | Uses a public `render()` method instead of defining render inside 87 | `__call__`, to let the user extend it if necessary. 88 | """ 89 | return self.render 90 | -------------------------------------------------------------------------------- /src/cornice/resource.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # This Source Code Form is subject to the terms of the Mozilla Public 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 4 | # You can obtain one at http://mozilla.org/MPL/2.0/. 5 | import functools 6 | import warnings 7 | 8 | import venusian 9 | 10 | from cornice import Service 11 | 12 | 13 | def resource(depth=2, **kw): 14 | """Class decorator to declare resources. 15 | 16 | All the methods of this class named by the name of HTTP resources 17 | will be used as such. You can also prefix them by ``"collection_"`` and 18 | they will be treated as HTTP methods for the given collection path 19 | (collection_path), if any. 20 | 21 | :param depth: 22 | Which frame should be looked in default 2. 23 | 24 | :param kw: 25 | Keyword arguments configuring the resource. 26 | 27 | Here is an example:: 28 | 29 | @resource(collection_path='/users', path='/users/{id}') 30 | """ 31 | 32 | def wrapper(klass): 33 | return add_resource(klass, depth, **kw) 34 | 35 | return wrapper 36 | 37 | 38 | def add_resource(klass, depth=2, **kw): 39 | """Function to declare resources of a Class. 40 | 41 | All the methods of this class named by the name of HTTP resources 42 | will be used as such. You can also prefix them by ``"collection_"`` and 43 | they will be treated as HTTP methods for the given collection path 44 | (collection_path), if any. 45 | 46 | :param klass: 47 | The class (resource) on which to register the service. 48 | 49 | :param depth: 50 | Which frame should be looked in default 2. 51 | 52 | :param kw: 53 | Keyword arguments configuring the resource. 54 | 55 | 56 | Here is an example: 57 | 58 | .. code-block:: python 59 | 60 | class User(object): 61 | pass 62 | 63 | add_resource(User, collection_path='/users', path='/users/{id}') 64 | 65 | Alternatively if you want to reuse your existing pyramid routes: 66 | 67 | .. code-block:: python 68 | 69 | class User(object): 70 | pass 71 | 72 | add_resource(User, collection_pyramid_route='users', 73 | pyramid_route='user') 74 | 75 | """ 76 | 77 | services = {} 78 | 79 | if ("collection_pyramid_route" in kw or "pyramid_route" in kw) and ( 80 | "collection_path" in kw or "path" in kw 81 | ): 82 | raise ValueError("You use either paths or route names, not both") 83 | 84 | if "collection_path" in kw: 85 | if kw["collection_path"] == kw["path"]: 86 | msg = "Warning: collection_path and path are not distinct." 87 | warnings.warn(msg) 88 | 89 | prefixes = ("", "collection_") 90 | else: 91 | prefixes = ("",) 92 | 93 | if "collection_pyramid_route" in kw: 94 | if kw["collection_pyramid_route"] == kw["pyramid_route"]: 95 | msg = "Warning: collection_pyramid_route and pyramid_route are not distinct." 96 | warnings.warn(msg) 97 | 98 | prefixes = ("", "collection_") 99 | 100 | for prefix in prefixes: 101 | # get clean view arguments 102 | service_args = {} 103 | for k in list(kw): 104 | if k.startswith("collection_"): 105 | if prefix == "collection_": 106 | service_args[k[len(prefix) :]] = kw[k] 107 | elif k not in service_args: 108 | service_args[k] = kw[k] 109 | 110 | # auto-wire klass as its own view factory, unless one 111 | # is explicitly declared. 112 | if "factory" not in kw: 113 | service_args["factory"] = klass 114 | 115 | # create service 116 | service_name = service_args.pop("name", None) or klass.__name__.lower() 117 | service_name = prefix + service_name 118 | service = services[service_name] = Service(name=service_name, depth=depth, **service_args) 119 | # ensure the service comes with the same properties as the wrapped 120 | # resource 121 | functools.update_wrapper(service, klass) 122 | 123 | # initialize views 124 | for verb in ("get", "post", "put", "delete", "options", "patch"): 125 | view_attr = prefix + verb 126 | meth = getattr(klass, view_attr, None) 127 | 128 | if meth is not None: 129 | # if the method has a __views__ arguments, then it had 130 | # been decorated by a @view decorator. get back the name of 131 | # the decorated method so we can register it properly 132 | views = getattr(meth, "__views__", []) 133 | if views: 134 | for view_args in views: 135 | service.add_view(verb, view_attr, klass=klass, **view_args) 136 | else: 137 | service.add_view(verb, view_attr, klass=klass) 138 | 139 | setattr(klass, "_services", services) 140 | 141 | def callback(context, name, ob): 142 | # get the callbacks registered by the inner services 143 | # and call them from here when the @resource classes are being 144 | # scanned by venusian. 145 | for service in services.values(): 146 | config = context.config.with_package(info.module) 147 | config.add_cornice_service(service) 148 | 149 | info = venusian.attach(klass, callback, category="pyramid", depth=depth) 150 | 151 | return klass 152 | 153 | 154 | def view(**kw): 155 | """Method decorator to store view arguments when defining a resource with 156 | the @resource class decorator 157 | 158 | :param kw: 159 | Keyword arguments configuring the view. 160 | """ 161 | 162 | def wrapper(func): 163 | return add_view(func, **kw) 164 | 165 | return wrapper 166 | 167 | 168 | def add_view(func, **kw): 169 | """Method to store view arguments when defining a resource with 170 | the add_resource class method 171 | 172 | :param func: 173 | The func to hook to 174 | 175 | :param kw: 176 | Keyword arguments configuring the view. 177 | 178 | Example: 179 | 180 | .. code-block:: python 181 | 182 | class User(object): 183 | 184 | def __init__(self, request): 185 | self.request = request 186 | 187 | def collection_get(self): 188 | return {'users': _USERS.keys()} 189 | 190 | def get(self): 191 | return _USERS.get(int(self.request.matchdict['id'])) 192 | 193 | add_view(User.get, renderer='json') 194 | add_resource(User, collection_path='/users', path='/users/{id}') 195 | """ 196 | # XXX needed in py2 to set on instancemethod 197 | if hasattr(func, "__func__"): # pragma: no cover 198 | func = func.__func__ 199 | # store view argument to use them later in @resource 200 | views = getattr(func, "__views__", None) 201 | if views is None: 202 | views = [] 203 | setattr(func, "__views__", views) 204 | views.append(kw) 205 | return func 206 | -------------------------------------------------------------------------------- /src/cornice/util.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 | # You can obtain one at http://mozilla.org/MPL/2.0/. 4 | import warnings 5 | 6 | 7 | __all__ = [ 8 | "is_string", 9 | "to_list", 10 | "match_accept_header", 11 | "ContentTypePredicate", 12 | "current_service", 13 | "func_name", 14 | ] 15 | 16 | 17 | def is_string(s): 18 | return isinstance(s, str) 19 | 20 | 21 | def to_list(obj): 22 | """Convert an object to a list if it is not already one""" 23 | if not isinstance(obj, (list, tuple)): 24 | obj = [ 25 | obj, 26 | ] 27 | return obj 28 | 29 | 30 | def match_accept_header(func, context, request): 31 | """ 32 | Return True if the request ``Accept`` header match 33 | the list returned by the callable specified in :param:func. 34 | 35 | Also attach the total list of possible accepted 36 | egress media types to the request. 37 | 38 | Utility function for performing content negotiation. 39 | 40 | :param func: 41 | The callable returning the list of acceptable 42 | internet media types for content negotiation. 43 | It obtains the request object as single argument. 44 | """ 45 | acceptable = to_list(func(request)) 46 | request.info["acceptable"] = acceptable 47 | return len(request.accept.acceptable_offers(acceptable)) > 0 48 | 49 | 50 | def match_content_type_header(func, context, request): 51 | """ 52 | Return True if the request ``Content-Type`` header match 53 | the list returned by the callable specified in :param:func. 54 | 55 | Also attach the total list of possible accepted 56 | ingress media types to the request. 57 | 58 | Utility function for performing request body 59 | media type checks. 60 | 61 | :param func: 62 | The callable returning the list of acceptable 63 | internet media types for request body 64 | media type checks. 65 | It obtains the request object as single argument. 66 | """ 67 | supported_contenttypes = to_list(func(request)) 68 | request.info["supported_contenttypes"] = supported_contenttypes 69 | return content_type_matches(request, supported_contenttypes) 70 | 71 | 72 | def extract_json_data(request): 73 | warnings.warn("Use ``cornice.validators.extract_cstruct()`` instead", DeprecationWarning) 74 | from cornice.validators import extract_cstruct 75 | 76 | return extract_cstruct(request)["body"] 77 | 78 | 79 | def extract_form_urlencoded_data(request): 80 | warnings.warn("Use ``cornice.validators.extract_cstruct()`` instead", DeprecationWarning) 81 | return request.POST 82 | 83 | 84 | def content_type_matches(request, content_types): 85 | """ 86 | Check whether ``request.content_type`` 87 | matches given list of content types. 88 | """ 89 | return request.content_type in content_types 90 | 91 | 92 | class ContentTypePredicate(object): 93 | """ 94 | Pyramid predicate for matching against ``Content-Type`` request header. 95 | Should live in ``pyramid.config.predicates``. 96 | 97 | .. seealso:: 98 | http://docs.pylonsproject.org/projects/pyramid/en/latest/narr/hooks.html 99 | #view-and-route-predicates 100 | """ 101 | 102 | def __init__(self, val, config): 103 | self.val = val 104 | 105 | def text(self): 106 | return "content_type = %s" % (self.val,) 107 | 108 | phash = text 109 | 110 | def __call__(self, context, request): 111 | return request.content_type == self.val 112 | 113 | 114 | def func_name(f): 115 | """Return the name of a function or class method.""" 116 | if isinstance(f, str): 117 | return f 118 | elif hasattr(f, "__qualname__"): # pragma: no cover 119 | return f.__qualname__ # Python 3 120 | elif hasattr(f, "im_class"): # pragma: no cover 121 | return "{0}.{1}".format(f.im_class.__name__, f.__name__) # Python 2 122 | else: # pragma: no cover 123 | return f.__name__ # Python 2 124 | 125 | 126 | def current_service(request): 127 | """Return the Cornice service matching the specified request. 128 | 129 | :returns: the service or None if unmatched. 130 | :rtype: cornice.Service 131 | """ 132 | if request.matched_route: 133 | services = request.registry.cornice_services 134 | pattern = request.matched_route.pattern 135 | name = request.matched_route.name 136 | # try pattern first, then route name else return None 137 | service = services.get(pattern, services.get("__cornice" + name)) 138 | return service 139 | -------------------------------------------------------------------------------- /src/cornice/validators/__init__.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 | # You can obtain one at http://mozilla.org/MPL/2.0/. 4 | import re 5 | 6 | from webob.multidict import MultiDict 7 | 8 | from cornice.validators._colander import body_validator as colander_body_validator 9 | from cornice.validators._colander import headers_validator as colander_headers_validator 10 | from cornice.validators._colander import path_validator as colander_path_validator 11 | from cornice.validators._colander import querystring_validator as colander_querystring_validator 12 | from cornice.validators._colander import validator as colander_validator 13 | from cornice.validators._marshmallow import body_validator as marshmallow_body_validator 14 | from cornice.validators._marshmallow import headers_validator as marshmallow_headers_validator 15 | from cornice.validators._marshmallow import path_validator as marshmallow_path_validator 16 | from cornice.validators._marshmallow import ( 17 | querystring_validator as marshmallow_querystring_validator, 18 | ) 19 | from cornice.validators._marshmallow import validator as marshmallow_validator 20 | 21 | 22 | __all__ = [ 23 | "colander_validator", 24 | "colander_body_validator", 25 | "colander_headers_validator", 26 | "colander_path_validator", 27 | "colander_querystring_validator", 28 | "marshmallow_validator", 29 | "marshmallow_body_validator", 30 | "marshmallow_headers_validator", 31 | "marshmallow_path_validator", 32 | "marshmallow_querystring_validator", 33 | "extract_cstruct", 34 | "DEFAULT_VALIDATORS", 35 | "DEFAULT_FILTERS", 36 | ] 37 | 38 | 39 | DEFAULT_VALIDATORS = [] 40 | DEFAULT_FILTERS = [] 41 | 42 | 43 | def extract_cstruct(request): 44 | """ 45 | Extract attributes from the specified `request` such as body, url, path, 46 | method, querystring, headers, cookies, and returns them in a single dict 47 | object. 48 | 49 | :param request: Current request 50 | :type request: :class:`~pyramid:pyramid.request.Request` 51 | 52 | :returns: A mapping containing most request attributes. 53 | :rtype: dict 54 | """ 55 | is_json = re.match("^application/(.*?)json$", str(request.content_type)) 56 | 57 | if request.content_type in ("application/x-www-form-urlencoded", "multipart/form-data"): 58 | body = request.POST.mixed() 59 | elif request.content_type and not is_json: 60 | body = request.body 61 | else: 62 | if request.body: 63 | try: 64 | body = request.json_body 65 | except ValueError as e: 66 | request.errors.add("body", "", "Invalid JSON: %s" % e) 67 | return {} 68 | else: 69 | if not hasattr(body, "items") and not isinstance(body, list): 70 | request.errors.add("body", "", "Should be a JSON object or an array") 71 | return {} 72 | else: 73 | body = {} 74 | 75 | cstruct = { 76 | "method": request.method, 77 | "url": request.url, 78 | "path": request.matchdict, 79 | "body": body, 80 | } 81 | 82 | for sub, attr in (("querystring", "GET"), ("header", "headers"), ("cookies", "cookies")): 83 | data = getattr(request, attr) 84 | if isinstance(data, MultiDict): 85 | data = data.mixed() 86 | else: 87 | data = dict(data) 88 | cstruct[sub] = data 89 | 90 | return cstruct 91 | -------------------------------------------------------------------------------- /src/cornice/validators/_colander.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 | # You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import inspect 6 | import warnings 7 | 8 | 9 | def _generate_colander_validator(location): 10 | """ 11 | Generate a colander validator for data from the given location. 12 | 13 | :param location: The location in the request to find the data to be 14 | validated, such as "body" or "querystring". 15 | :type location: str 16 | :return: Returns a callable that will validate the request at the given 17 | location. 18 | :rtype: callable 19 | """ 20 | 21 | def _validator(request, schema=None, deserializer=None, **kwargs): 22 | """ 23 | Validate the location against the schema defined on the service. 24 | 25 | The content of the location is deserialized, validated and stored in 26 | the ``request.validated`` attribute. 27 | 28 | .. note:: 29 | 30 | If no schema is defined, this validator does nothing. 31 | Schema should be of type :class:`~colander:colander.MappingSchema`. 32 | 33 | :param request: Current request 34 | :type request: :class:`~pyramid:pyramid.request.Request` 35 | 36 | :param schema: The Colander schema 37 | :param deserializer: Optional deserializer, defaults to 38 | :func:`cornice.validators.extract_cstruct` 39 | """ 40 | import colander 41 | 42 | if schema is None: 43 | return 44 | 45 | schema_instance = _ensure_instantiated(schema) 46 | 47 | if not isinstance(schema_instance, colander.MappingSchema): 48 | raise TypeError("Schema should inherit from colander.MappingSchema.") 49 | 50 | class RequestSchemaMeta(colander._SchemaMeta): 51 | """ 52 | A metaclass that will inject a location class attribute into 53 | RequestSchema. 54 | """ 55 | 56 | def __new__(cls, name, bases, class_attrs): 57 | """ 58 | Instantiate the RequestSchema class. 59 | 60 | :param name: The name of the class we are instantiating. Will 61 | be "RequestSchema". 62 | :type name: str 63 | :param bases: The class's superclasses. 64 | :type bases: tuple 65 | :param dct: The class's class attributes. 66 | :type dct: dict 67 | """ 68 | class_attrs[location] = schema_instance 69 | return type(name, bases, class_attrs) 70 | 71 | class RequestSchema(colander.MappingSchema, metaclass=RequestSchemaMeta): # noqa 72 | """A schema to validate the request's location attributes.""" 73 | 74 | pass 75 | 76 | validator(request, RequestSchema(), deserializer, **kwargs) 77 | validated_location = request.validated.get(location, {}) 78 | request.validated.update(validated_location) 79 | if location not in validated_location: 80 | request.validated.pop(location, None) 81 | 82 | return _validator 83 | 84 | 85 | body_validator = _generate_colander_validator("body") 86 | headers_validator = _generate_colander_validator("headers") 87 | path_validator = _generate_colander_validator("path") 88 | querystring_validator = _generate_colander_validator("querystring") 89 | 90 | 91 | def validator(request, schema=None, deserializer=None, **kwargs): 92 | """ 93 | Validate the full request against the schema defined on the service. 94 | 95 | Each attribute of the request is deserialized, validated and stored in the 96 | ``request.validated`` attribute 97 | (eg. body in ``request.validated['body']``). 98 | 99 | .. note:: 100 | 101 | If no schema is defined, this validator does nothing. 102 | 103 | :param request: Current request 104 | :type request: :class:`~pyramid:pyramid.request.Request` 105 | 106 | :param schema: The Colander schema 107 | :param deserializer: Optional deserializer, defaults to 108 | :func:`cornice.validators.extract_cstruct` 109 | """ 110 | import colander 111 | 112 | from cornice.validators import extract_cstruct 113 | 114 | if deserializer is None: 115 | deserializer = extract_cstruct 116 | 117 | if schema is None: 118 | return 119 | 120 | schema = _ensure_instantiated(schema) 121 | cstruct = deserializer(request) 122 | try: 123 | deserialized = schema.deserialize(cstruct) 124 | except colander.Invalid as e: 125 | translate = request.localizer.translate 126 | error_dict = e.asdict(translate=translate) 127 | for name, msg in error_dict.items(): 128 | location, _, field = name.partition(".") 129 | request.errors.add(location, field, msg) 130 | else: 131 | request.validated.update(deserialized) 132 | 133 | 134 | def _ensure_instantiated(schema): 135 | if inspect.isclass(schema): 136 | warnings.warn( 137 | "Setting schema to a class is deprecated. Set schema to an instance instead.", 138 | DeprecationWarning, 139 | stacklevel=2, 140 | ) 141 | schema = schema() 142 | return schema 143 | -------------------------------------------------------------------------------- /src/cornice/validators/_marshmallow.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 | # You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import inspect 6 | 7 | 8 | def _generate_marshmallow_validator(location): 9 | """ 10 | Generate a marshmallow validator for data from the given location. 11 | 12 | :param location: The location in the request to find the data to be 13 | validated, such as "body" or "querystring". 14 | :type location: str 15 | :return: Returns a callable that will validate the request at the given 16 | location. 17 | :rtype: callable 18 | """ 19 | 20 | def _validator(request, schema=None, deserializer=None, **kwargs): 21 | """ 22 | Validate the location against the schema defined on the service. 23 | 24 | The content of the location is deserialized, validated and stored in 25 | the ``request.validated`` attribute. 26 | 27 | Keyword arguments to be included when initialising the marshmallow 28 | schema can be passed as a dict in ``kwargs['schema_kwargs']`` variable. 29 | 30 | .. note:: 31 | 32 | If no schema is defined, this validator does nothing. 33 | 34 | :param request: Current request 35 | :type request: :class:`~pyramid:pyramid.request.Request` 36 | 37 | :param schema: The marshmallow schema 38 | :param deserializer: Optional deserializer, defaults to 39 | :func:`cornice.validators.extract_cstruct` 40 | """ 41 | import marshmallow 42 | import marshmallow.schema 43 | from marshmallow.utils import EXCLUDE 44 | 45 | if schema is None: 46 | return 47 | 48 | # see if the user wants to set any keyword arguments for their schema 49 | schema_kwargs = kwargs.get("schema_kwargs", {}) 50 | schema = _instantiate_schema(schema, **schema_kwargs) 51 | 52 | class ValidatedField(marshmallow.fields.Field): 53 | def _deserialize(self, value, attr, data, **kwargs): 54 | schema.context.setdefault("request", request) 55 | deserialized = schema.load(value) 56 | return deserialized 57 | 58 | class Meta(object): 59 | strict = True 60 | ordered = True 61 | unknown = EXCLUDE 62 | 63 | class RequestSchemaMeta(marshmallow.schema.SchemaMeta): 64 | """ 65 | A metaclass that will inject a location class attribute into 66 | RequestSchema. 67 | """ 68 | 69 | def __new__(cls, name, bases, class_attrs): 70 | """ 71 | Instantiate the RequestSchema class. 72 | 73 | :param name: The name of the class we are instantiating. Will 74 | be "RequestSchema". 75 | :type name: str 76 | :param bases: The class's superclasses. 77 | :type bases: tuple 78 | :param dct: The class's class attributes. 79 | :type dct: dict 80 | """ 81 | 82 | class_attrs[location] = ValidatedField( 83 | required=True, metadata={"load_from": location} 84 | ) 85 | class_attrs["Meta"] = Meta 86 | return type(name, bases, class_attrs) 87 | 88 | class RequestSchema(marshmallow.Schema, metaclass=RequestSchemaMeta): # noqa 89 | """A schema to validate the request's location attributes.""" 90 | 91 | pass 92 | 93 | validator(request, RequestSchema, deserializer, **kwargs) 94 | request.validated = request.validated.get(location, {}) 95 | 96 | return _validator 97 | 98 | 99 | body_validator = _generate_marshmallow_validator("body") 100 | headers_validator = _generate_marshmallow_validator("header") 101 | path_validator = _generate_marshmallow_validator("path") 102 | querystring_validator = _generate_marshmallow_validator("querystring") 103 | 104 | 105 | def _message_normalizer(exc, no_field_name="_schema"): 106 | """ 107 | Normally `normalize_messages` will exist on `ValidationError` but pre 2.10 108 | versions don't have it 109 | :param exc: 110 | :param no_field_name: 111 | :return: 112 | """ 113 | if isinstance(exc.messages, dict): 114 | return exc.messages 115 | field_names = exc.kwargs.get("field_names", []) 116 | if len(field_names) == 0: 117 | return {no_field_name: exc.messages} 118 | return dict((name, exc.messages) for name in field_names) 119 | 120 | 121 | def validator(request, schema=None, deserializer=None, **kwargs): 122 | """ 123 | Validate the full request against the schema defined on the service. 124 | 125 | Each attribute of the request is deserialized, validated and stored in the 126 | ``request.validated`` attribute 127 | (eg. body in ``request.validated['body']``). 128 | 129 | .. note:: 130 | 131 | If no schema is defined, this validator does nothing. 132 | 133 | :param request: Current request 134 | :type request: :class:`~pyramid:pyramid.request.Request` 135 | 136 | :param schema: The marshmallow schema 137 | :param deserializer: Optional deserializer, defaults to 138 | :func:`cornice.validators.extract_cstruct` 139 | """ 140 | import marshmallow 141 | 142 | from cornice.validators import extract_cstruct 143 | 144 | if deserializer is None: 145 | deserializer = extract_cstruct 146 | 147 | if schema is None: 148 | return 149 | 150 | schema = _instantiate_schema(schema) 151 | schema.context.setdefault("request", request) 152 | 153 | cstruct = deserializer(request) 154 | try: 155 | deserialized = schema.load(cstruct) 156 | except marshmallow.ValidationError as err: 157 | # translate = request.localizer.translate 158 | normalized_errors = _message_normalizer(err) 159 | for location, details in normalized_errors.items(): 160 | location = location if location != "_schema" else "" 161 | if hasattr(details, "items"): 162 | for subfield, msg in details.items(): 163 | request.errors.add(location, subfield, msg) 164 | else: 165 | request.errors.add(location, location, details) 166 | else: 167 | request.validated.update(deserialized) 168 | 169 | 170 | def _instantiate_schema(schema, **kwargs): 171 | """ 172 | Returns an object of the given marshmallow schema. 173 | 174 | :param schema: The marshmallow schema class with which the request should 175 | be validated 176 | :param kwargs: The keyword arguments that will be provided to the 177 | marshmallow schema's constructor 178 | :return: The object of the marshmallow schema 179 | """ 180 | if not inspect.isclass(schema): 181 | raise ValueError("You need to pass Marshmallow class instead of schema instance") 182 | return schema(**kwargs) 183 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 | # You can obtain one at http://mozilla.org/MPL/2.0/. 4 | -------------------------------------------------------------------------------- /tests/support.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 | # You can obtain one at http://mozilla.org/MPL/2.0/. 4 | import logging 5 | import logging.handlers 6 | import weakref 7 | 8 | 9 | try: 10 | from unittest2 import TestCase 11 | except ImportError: 12 | # Maybe we're running in python2.7? 13 | from unittest import TestCase # NOQA 14 | 15 | from pyramid import testing 16 | from pyramid.httpexceptions import HTTPException 17 | from webob import exc 18 | from webob.dec import wsgify 19 | 20 | from cornice.errors import Errors 21 | 22 | 23 | logger = logging.getLogger("cornice") 24 | 25 | 26 | class DummyContext(object): 27 | def __repr__(self): 28 | return "context!" 29 | 30 | 31 | class DummyRequest(testing.DummyRequest): 32 | def __init__(self, *args, **kwargs): 33 | super(DummyRequest, self).__init__(*args, **kwargs) 34 | self.context = DummyContext() 35 | self.errors = Errors() 36 | self.content_type = None 37 | 38 | 39 | def dummy_factory(request): 40 | return DummyContext() 41 | 42 | 43 | # stolen from the packaging stdlib testsuite tools 44 | 45 | 46 | class _TestHandler(logging.handlers.BufferingHandler): 47 | # stolen and adapted from test.support 48 | 49 | def __init__(self): 50 | logging.handlers.BufferingHandler.__init__(self, 0) 51 | self.setLevel(logging.DEBUG) 52 | 53 | def shouldFlush(self): 54 | return False 55 | 56 | def emit(self, record): 57 | self.buffer.append(record) 58 | 59 | 60 | class LoggingCatcher(object): 61 | """TestCase-compatible mixin to receive logging calls. 62 | 63 | Upon setUp, instances of this classes get a BufferingHandler that's 64 | configured to record all messages logged to the 'cornice' logger 65 | """ 66 | 67 | def setUp(self): 68 | super(LoggingCatcher, self).setUp() 69 | self.loghandler = handler = _TestHandler() 70 | self._old_level = logger.level 71 | logger.addHandler(handler) 72 | logger.setLevel(logging.DEBUG) # we want all messages 73 | 74 | def tearDown(self): 75 | handler = self.loghandler 76 | # All this is necessary to properly shut down the logging system and 77 | # avoid a regrtest complaint. Thanks to Vinay Sajip for the help. 78 | handler.close() 79 | logger.removeHandler(handler) 80 | for ref in weakref.getweakrefs(handler): 81 | logging._removeHandlerRef(ref) 82 | del self.loghandler 83 | logger.setLevel(self._old_level) 84 | super(LoggingCatcher, self).tearDown() 85 | 86 | def get_logs(self, level=logging.WARNING, flush=True): 87 | """Return all log messages with given level. 88 | 89 | *level* defaults to logging.WARNING. 90 | 91 | For log calls with arguments (i.e. logger.info('bla bla %r', arg)), 92 | the messages will be formatted before being returned (e.g. "bla bla 93 | 'thing'"). 94 | 95 | Returns a list. Automatically flushes the loghandler after being 96 | called, unless *flush* is False (this is useful to get e.g. all 97 | warnings then all info messages). 98 | """ 99 | messages = [log.getMessage() for log in self.loghandler.buffer if log.levelno == level] 100 | if flush: 101 | self.loghandler.flush() 102 | return messages 103 | 104 | 105 | class CatchErrors(object): 106 | def __init__(self, app): 107 | self.app = app 108 | if hasattr(app, "registry"): 109 | self.registry = app.registry 110 | 111 | @wsgify 112 | def __call__(self, request): 113 | try: 114 | return request.get_response(self.app) 115 | except (exc.HTTPException, HTTPException) as e: 116 | return e 117 | -------------------------------------------------------------------------------- /tests/test_cors.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 | # You can obtain one at http://mozilla.org/MPL/2.0/. 4 | from pyramid import testing 5 | from pyramid.authentication import BasicAuthAuthenticationPolicy 6 | from pyramid.exceptions import HTTPBadRequest, NotFound 7 | from pyramid.interfaces import IAuthorizationPolicy 8 | from pyramid.response import Response 9 | from pyramid.view import view_config 10 | from webtest import TestApp 11 | from zope.interface import implementer 12 | 13 | from cornice.service import Service 14 | 15 | from .support import CatchErrors, TestCase 16 | 17 | 18 | squirel = Service(path="/squirel", name="squirel", cors_origins=("foobar",)) 19 | spam = Service(path="/spam", name="spam", cors_origins=("*",)) 20 | eggs = Service(path="/eggs", name="egg", cors_origins=("*",), cors_expose_all_headers=False) 21 | bacon = Service(path="/bacon/{type}", name="bacon", cors_origins=("*",)) 22 | 23 | 24 | class Klass(object): 25 | """ 26 | Class implementation of a service 27 | """ 28 | 29 | def __init__(self, request): 30 | self.request = request 31 | 32 | def post(self): 33 | return "moar squirels (take care)" 34 | 35 | 36 | cors_policy = {"origins": ("*",), "enabled": True, "credentials": True} 37 | 38 | cors_klass = Service(name="cors_klass", path="/cors_klass", klass=Klass, cors_policy=cors_policy) 39 | cors_klass.add_view("post", "post") 40 | 41 | 42 | @squirel.get(cors_origins=("notmyidea.org",), cors_headers=("X-My-Header",)) 43 | def get_squirel(request): 44 | return "squirels" 45 | 46 | 47 | @squirel.post(cors_enabled=False, cors_headers=("X-Another-Header")) 48 | def post_squirel(request): 49 | return "moar squirels (take care)" 50 | 51 | 52 | @squirel.put() 53 | def put_squirel(request): 54 | return "squirels!" 55 | 56 | 57 | @spam.get(cors_credentials=True, cors_headers=("X-My-Header"), cors_max_age=42) 58 | def gimme_some_spam_please(request): 59 | return "spam" 60 | 61 | 62 | @spam.post(permission="read-only") 63 | def moar_spam(request): 64 | return "moar spam" 65 | 66 | 67 | @eggs.get( 68 | cors_origins=("notmyidea.org",), 69 | cors_headers=("X-My-Header", "X-Another-Header", "X-Another-Header2"), 70 | ) 71 | def get_eggs(request): 72 | return "eggs" 73 | 74 | 75 | def is_bacon_good(request, **kwargs): 76 | if not request.matchdict["type"].endswith("good"): 77 | request.errors.add("querystring", "type", "should be better!") 78 | 79 | 80 | @bacon.get(validators=is_bacon_good) 81 | def get_some_bacon(request): 82 | # Okay, you there. Bear in mind, the only kind of bacon existing is 'good'. 83 | if request.matchdict["type"] != "good": 84 | raise NotFound(detail="Not. Found.") 85 | return "yay" 86 | 87 | 88 | @bacon.post() 89 | def post_some_bacon(request): 90 | return Response() 91 | 92 | 93 | @bacon.put() 94 | def put_some_bacon(request): 95 | raise HTTPBadRequest() 96 | 97 | 98 | @view_config(route_name="noservice") 99 | def noservice(request): 100 | return Response("No Service here.") 101 | 102 | 103 | class TestCORS(TestCase): 104 | def setUp(self): 105 | self.config = testing.setUp() 106 | self.config.include("cornice") 107 | self.config.add_route("noservice", "/noservice") 108 | self.config.scan("tests.test_cors") 109 | self.app = TestApp(CatchErrors(self.config.make_wsgi_app())) 110 | 111 | def tearDown(self): 112 | testing.tearDown() 113 | 114 | def test_preflight_cors_klass_post(self): 115 | resp = self.app.options( 116 | "/cors_klass", 117 | status=200, 118 | headers={"Origin": "lolnet.org", "Access-Control-Request-Method": "POST"}, 119 | ) 120 | self.assertEqual("POST,OPTIONS", dict(resp.headers)["Access-Control-Allow-Methods"]) 121 | 122 | def test_preflight_cors_klass_put(self): 123 | self.app.options( 124 | "/cors_klass", 125 | status=400, 126 | headers={"Origin": "lolnet.org", "Access-Control-Request-Method": "PUT"}, 127 | ) 128 | 129 | def test_preflight_missing_headers(self): 130 | # we should have an OPTION method defined. 131 | # If we just try to reach it, without using correct headers: 132 | # "Access-Control-Request-Method"or without the "Origin" header, 133 | # we should get a 400. 134 | resp = self.app.options("/squirel", status=400) 135 | self.assertEqual(len(resp.json["errors"]), 2) 136 | 137 | def test_preflight_missing_origin(self): 138 | resp = self.app.options( 139 | "/squirel", headers={"Access-Control-Request-Method": "GET"}, status=400 140 | ) 141 | self.assertEqual(len(resp.json["errors"]), 1) 142 | 143 | def test_preflight_does_not_expose_headers(self): 144 | resp = self.app.options( 145 | "/squirel", 146 | headers={"Access-Control-Request-Method": "GET", "Origin": "notmyidea.org"}, 147 | status=200, 148 | ) 149 | self.assertNotIn("Access-Control-Expose-Headers", resp.headers) 150 | 151 | def test_preflight_missing_request_method(self): 152 | resp = self.app.options("/squirel", headers={"Origin": "foobar.org"}, status=400) 153 | 154 | self.assertEqual(len(resp.json["errors"]), 1) 155 | 156 | def test_preflight_incorrect_origin(self): 157 | # we put "lolnet.org" where only "notmyidea.org" is authorized 158 | resp = self.app.options( 159 | "/squirel", 160 | headers={"Origin": "lolnet.org", "Access-Control-Request-Method": "GET"}, 161 | status=400, 162 | ) 163 | self.assertEqual(len(resp.json["errors"]), 1) 164 | 165 | def test_preflight_correct_origin(self): 166 | resp = self.app.options( 167 | "/squirel", headers={"Origin": "notmyidea.org", "Access-Control-Request-Method": "GET"} 168 | ) 169 | self.assertEqual(resp.headers["Access-Control-Allow-Origin"], "notmyidea.org") 170 | 171 | allowed_methods = resp.headers["Access-Control-Allow-Methods"].split(",") 172 | 173 | self.assertNotIn("POST", allowed_methods) 174 | self.assertIn("GET", allowed_methods) 175 | self.assertIn("PUT", allowed_methods) 176 | self.assertIn("HEAD", allowed_methods) 177 | 178 | allowed_headers = resp.headers["Access-Control-Allow-Headers"].split(",") 179 | 180 | self.assertIn("X-My-Header", allowed_headers) 181 | self.assertNotIn("X-Another-Header", allowed_headers) 182 | 183 | def test_preflight_deactivated_method(self): 184 | self.app.options( 185 | "/squirel", 186 | headers={"Origin": "notmyidea.org", "Access-Control-Request-Method": "POST"}, 187 | status=400, 188 | ) 189 | 190 | def test_preflight_origin_not_allowed_for_method(self): 191 | self.app.options( 192 | "/squirel", 193 | headers={"Origin": "notmyidea.org", "Access-Control-Request-Method": "PUT"}, 194 | status=400, 195 | ) 196 | 197 | def test_preflight_credentials_are_supported(self): 198 | resp = self.app.options( 199 | "/spam", headers={"Origin": "notmyidea.org", "Access-Control-Request-Method": "GET"} 200 | ) 201 | self.assertIn("Access-Control-Allow-Credentials", resp.headers) 202 | self.assertEqual(resp.headers["Access-Control-Allow-Credentials"], "true") 203 | 204 | def test_preflight_credentials_header_not_included_when_not_needed(self): 205 | resp = self.app.options( 206 | "/spam", headers={"Origin": "notmyidea.org", "Access-Control-Request-Method": "POST"} 207 | ) 208 | 209 | self.assertNotIn("Access-Control-Allow-Credentials", resp.headers) 210 | 211 | def test_preflight_contains_max_age(self): 212 | resp = self.app.options( 213 | "/spam", headers={"Origin": "notmyidea.org", "Access-Control-Request-Method": "GET"} 214 | ) 215 | 216 | self.assertIn("Access-Control-Max-Age", resp.headers) 217 | self.assertEqual(resp.headers["Access-Control-Max-Age"], "42") 218 | 219 | def test_resp_dont_include_allow_origin(self): 220 | resp = self.app.get("/squirel") # omit the Origin header 221 | self.assertNotIn("Access-Control-Allow-Origin", resp.headers) 222 | self.assertEqual(resp.json, "squirels") 223 | 224 | def test_origin_is_not_wildcard_if_allow_credentials(self): 225 | resp = self.app.options( 226 | "/cors_klass", 227 | status=200, 228 | headers={ 229 | "Origin": "lolnet.org", 230 | "Access-Control-Request-Method": "POST", 231 | }, 232 | ) 233 | self.assertEqual(resp.headers["Access-Control-Allow-Origin"], "lolnet.org") 234 | self.assertEqual(resp.headers["Access-Control-Allow-Credentials"], "true") 235 | 236 | def test_responses_include_an_allow_origin_header(self): 237 | resp = self.app.get("/squirel", headers={"Origin": "notmyidea.org"}) 238 | self.assertIn("Access-Control-Allow-Origin", resp.headers) 239 | self.assertEqual(resp.headers["Access-Control-Allow-Origin"], "notmyidea.org") 240 | 241 | def test_credentials_are_included(self): 242 | resp = self.app.get("/spam", headers={"Origin": "notmyidea.org"}) 243 | self.assertIn("Access-Control-Allow-Credentials", resp.headers) 244 | self.assertEqual(resp.headers["Access-Control-Allow-Credentials"], "true") 245 | 246 | def test_headers_are_exposed(self): 247 | resp = self.app.get("/squirel", headers={"Origin": "notmyidea.org"}) 248 | self.assertIn("Access-Control-Expose-Headers", resp.headers) 249 | 250 | headers = resp.headers["Access-Control-Expose-Headers"].split(",") 251 | self.assertIn("X-My-Header", headers) 252 | 253 | def test_preflight_request_headers_are_included(self): 254 | resp = self.app.options( 255 | "/squirel", 256 | headers={ 257 | "Origin": "notmyidea.org", 258 | "Access-Control-Request-Method": "GET", 259 | "Access-Control-Request-Headers": "foo, bar,baz ", 260 | }, 261 | ) 262 | # The specification says we can have any number of LWS (Linear white 263 | # spaces) in the values and that it should be removed. 264 | 265 | # per default, they should be authorized, and returned in the list of 266 | # authorized headers 267 | headers = resp.headers["Access-Control-Allow-Headers"].split(",") 268 | self.assertIn("foo", headers) 269 | self.assertIn("bar", headers) 270 | self.assertIn("baz", headers) 271 | 272 | def test_preflight_request_headers_isnt_too_permissive(self): 273 | # The specification says we can have any number of LWS (Linear white 274 | # spaces) in the values. 275 | self.app.options( 276 | "/eggs", 277 | headers={ 278 | "Origin": "notmyidea.org", 279 | "Access-Control-Request-Method": "GET", 280 | "Access-Control-Request-Headers": ( 281 | " X-My-Header ,X-Another-Header, X-Another-Header2 " 282 | ), 283 | }, 284 | status=200, 285 | ) 286 | 287 | self.app.options( 288 | "/eggs", 289 | headers={ 290 | "Origin": "notmyidea.org", 291 | "Access-Control-Request-Method": "GET", 292 | "Access-Control-Request-Headers": ("X-My-Header ,baz , X-Another-Header "), 293 | }, 294 | status=400, 295 | ) 296 | 297 | def test_preflight_headers_arent_case_sensitive(self): 298 | self.app.options( 299 | "/spam", 300 | headers={ 301 | "Origin": "notmyidea.org", 302 | "Access-Control-Request-Method": "GET", 303 | "Access-Control-Request-Headers": "x-my-header", 304 | }, 305 | ) 306 | 307 | def test_400_returns_CORS_headers(self): 308 | resp = self.app.get("/bacon/not", status=400, headers={"Origin": "notmyidea.org"}) 309 | self.assertIn("Access-Control-Allow-Origin", resp.headers) 310 | 311 | def test_404_returns_CORS_headers(self): 312 | resp = self.app.get("/bacon/notgood", status=404, headers={"Origin": "notmyidea.org"}) 313 | self.assertIn("Access-Control-Allow-Origin", resp.headers) 314 | 315 | def test_response_returns_CORS_headers(self): 316 | resp = self.app.post("/bacon/response", status=200, headers={"Origin": "notmyidea.org"}) 317 | self.assertIn("Access-Control-Allow-Origin", resp.headers) 318 | 319 | def test_raise_returns_CORS_headers(self): 320 | resp = self.app.put("/bacon/raise", status=400, headers={"Origin": "notmyidea.org"}) 321 | self.assertIn("Access-Control-Allow-Origin", resp.headers) 322 | 323 | def test_existing_non_service_route(self): 324 | resp = self.app.get("/noservice", status=200, headers={"Origin": "notmyidea.org"}) 325 | self.assertEqual(resp.body, b"No Service here.") 326 | 327 | 328 | class TestAuthenticatedCORS(TestCase): 329 | def setUp(self): 330 | def check_cred(username, *args, **kwargs): 331 | return [username] 332 | 333 | @implementer(IAuthorizationPolicy) 334 | class AuthorizationPolicy(object): 335 | def permits(self, context, principals, permission): 336 | return permission in principals 337 | 338 | self.config = testing.setUp() 339 | self.config.include("cornice") 340 | self.config.add_route("noservice", "/noservice") 341 | self.config.set_authorization_policy(AuthorizationPolicy()) 342 | self.config.set_authentication_policy(BasicAuthAuthenticationPolicy(check_cred)) 343 | self.config.set_default_permission("readwrite") 344 | self.config.scan("tests.test_cors") 345 | self.app = TestApp(CatchErrors(self.config.make_wsgi_app())) 346 | 347 | def tearDown(self): 348 | testing.tearDown() 349 | 350 | def test_post_on_spam_should_be_forbidden(self): 351 | self.app.post("/spam", status=403) 352 | 353 | def test_preflight_does_not_need_authentication(self): 354 | self.app.options( 355 | "/spam", 356 | status=200, 357 | headers={"Origin": "notmyidea.org", "Access-Control-Request-Method": "POST"}, 358 | ) 359 | 360 | 361 | class TestAlwaysCORS(TestCase): 362 | def setUp(self): 363 | self.config = testing.setUp() 364 | self.config.include("cornice") 365 | self.config.add_settings({"cornice.always_cors": True}) 366 | self.config.add_route("noservice", "/noservice") 367 | self.config.scan("tests.test_cors") 368 | self.app = TestApp(CatchErrors(self.config.make_wsgi_app())) 369 | 370 | def tearDown(self): 371 | testing.tearDown() 372 | 373 | def test_response_returns_CORS_headers_without_origin(self): 374 | resp = self.app.post("/bacon/response", status=200) 375 | self.assertIn("Access-Control-Allow-Origin", resp.headers) 376 | 377 | def test_response_does_not_return_CORS_headers_if_no_origin(self): 378 | resp = self.app.put("/squirel") 379 | self.assertNotIn("Access-Control-Allow-Origin", resp.headers) 380 | 381 | def test_preflight_checks_origin_when_not_star(self): 382 | self.app.options( 383 | "/squirel", 384 | headers={"Origin": "notmyidea.org", "Access-Control-Request-Method": "PUT"}, 385 | status=400, 386 | ) 387 | self.app.put("/squirel", headers={"Origin": "notmyidea.org"}, status=400) 388 | 389 | def test_checks_origin_when_not_star(self): 390 | self.app.put("/squirel", headers={"Origin": "not foobar"}, status=400) 391 | -------------------------------------------------------------------------------- /tests/test_errors.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from pyramid import testing 4 | from pyramid.i18n import TranslationString 5 | from webtest import TestApp 6 | 7 | from cornice.errors import Errors 8 | from cornice.service import Service 9 | 10 | from .support import CatchErrors, TestCase 11 | 12 | 13 | class TestErrorsHelper(TestCase): 14 | def setUp(self): 15 | self.errors = Errors() 16 | 17 | def test_add_to_supported_location(self): 18 | self.errors.add("") 19 | self.errors.add("body", description="!") 20 | self.errors.add("querystring", name="field") 21 | self.errors.add("url") 22 | self.errors.add("header") 23 | self.errors.add("path") 24 | self.errors.add("cookies") 25 | self.errors.add("method") 26 | self.assertEqual(len(self.errors), 8) 27 | 28 | def test_raises_an_exception_when_location_is_unsupported(self): 29 | with self.assertRaises(ValueError): 30 | self.errors.add("something") 31 | 32 | 33 | service1 = Service(name="service1", path="/error-service1") 34 | 35 | 36 | @service1.get() 37 | def get1(request): 38 | return request.errors.add("body", "field", "Description") 39 | 40 | 41 | service2 = Service(name="service2", path="/error-service2") 42 | 43 | 44 | @service2.get() 45 | def get2(request): 46 | return request.errors.add("body", "field", TranslationString("Description")) 47 | 48 | 49 | class TestErrorsTranslation(TestCase): 50 | def setUp(self): 51 | self.config = testing.setUp() 52 | self.config.add_settings({"available_languages": "en fr"}) 53 | self.config.include("cornice") 54 | self.config.scan("tests.test_errors") 55 | self.app = TestApp(CatchErrors(self.config.make_wsgi_app())) 56 | 57 | def tearDown(self): 58 | testing.tearDown() 59 | 60 | @property 61 | def _translate(self): 62 | return "pyramid.i18n.Localizer.translate" 63 | 64 | def test_error_description_translation_not_called_when_string(self): 65 | with mock.patch(self._translate) as mocked: 66 | self.app.get("/error-service1", status=400).json 67 | self.assertFalse(mocked.called) 68 | 69 | def test_error_description_translation_called_when_translationstring(self): 70 | with mock.patch(self._translate, return_value="Translated") as mocked: 71 | self.app.get("/error-service2", status=400).json 72 | self.assertTrue(mocked.called) 73 | -------------------------------------------------------------------------------- /tests/test_imperative_resource.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 | # You can obtain one at http://mozilla.org/MPL/2.0/. 4 | import json 5 | from unittest import mock 6 | 7 | import pytest 8 | from pyramid import testing 9 | from pyramid.authentication import AuthTktAuthenticationPolicy 10 | from pyramid.authorization import ACLAuthorizationPolicy 11 | from pyramid.httpexceptions import HTTPForbidden, HTTPOk 12 | from pyramid.security import Allow 13 | from webtest import TestApp 14 | 15 | from cornice.resource import add_resource, add_view 16 | 17 | from .support import CatchErrors, TestCase, dummy_factory 18 | 19 | 20 | USERS = {1: {"name": "gawel"}, 2: {"name": "tarek"}} 21 | 22 | 23 | def my_collection_acl(request): 24 | return [(Allow, "alice", "read")] 25 | 26 | 27 | class ThingImp(object): 28 | def __init__(self, request, context=None): 29 | self.request = request 30 | self.context = context 31 | 32 | def __acl__(self): 33 | return my_collection_acl(self.request) 34 | 35 | def collection_get(self): 36 | return "yay" 37 | 38 | 39 | class UserImp(object): 40 | def __init__(self, request, context=None): 41 | self.request = request 42 | self.context = context 43 | 44 | def collection_get(self): 45 | return {"users": list(USERS.keys())} 46 | 47 | def get(self): 48 | return USERS.get(int(self.request.matchdict["id"])) 49 | 50 | def collection_post(self): 51 | return {"test": "yeah"} 52 | 53 | def patch(self): 54 | return {"test": "yeah"} 55 | 56 | def collection_patch(self): 57 | return {"test": "yeah"} 58 | 59 | def put(self): 60 | return dict(type=repr(self.context)) 61 | 62 | 63 | class TestResourceWarning(TestCase): 64 | @mock.patch("warnings.warn") 65 | def test_path_clash(self, mocked_warn): 66 | class BadThingImp(object): 67 | def __init__(self, request, context=None): 68 | pass 69 | 70 | add_resource( 71 | BadThingImp, 72 | collection_path="/badthing/{id}", 73 | path="/badthing/{id}", 74 | name="bad_thing_service", 75 | ) 76 | 77 | msg = "Warning: collection_path and path are not distinct." 78 | mocked_warn.assert_called_with(msg) 79 | 80 | 81 | class TestResource(TestCase): 82 | def setUp(self): 83 | from pyramid.renderers import JSONP 84 | 85 | self.config = testing.setUp() 86 | self.config.add_renderer("jsonp", JSONP(param_name="callback")) 87 | self.config.include("cornice") 88 | self.authz_policy = ACLAuthorizationPolicy() 89 | self.config.set_authorization_policy(self.authz_policy) 90 | 91 | self.authn_policy = AuthTktAuthenticationPolicy("$3kr1t") 92 | self.config.set_authentication_policy(self.authn_policy) 93 | 94 | add_view(ThingImp.collection_get, permission="read") 95 | thing_resource = add_resource( 96 | ThingImp, collection_path="/thing", path="/thing/{id}", name="thing_service" 97 | ) 98 | 99 | add_view(UserImp.get, renderer="json") 100 | add_view(UserImp.get, renderer="jsonp", accept="application/javascript") 101 | add_view(UserImp.collection_post, renderer="json", accept="application/json") 102 | user_resource = add_resource( 103 | UserImp, 104 | collection_path="/users", 105 | path="/users/{id}", 106 | name="user_service", 107 | factory=dummy_factory, 108 | ) 109 | 110 | self.config.add_cornice_resource(thing_resource) 111 | self.config.add_cornice_resource(user_resource) 112 | self.app = TestApp(CatchErrors(self.config.make_wsgi_app())) 113 | 114 | def tearDown(self): 115 | testing.tearDown() 116 | 117 | def test_basic_resource(self): 118 | self.assertEqual(self.app.get("/users").json, {"users": [1, 2]}) 119 | 120 | self.assertEqual(self.app.get("/users/1").json, {"name": "gawel"}) 121 | 122 | resp = self.app.get("/users/1?callback=test") 123 | 124 | self.assertIn(b'test({"name": "gawel"})', resp.body, msg=resp.body) 125 | 126 | def test_accept_headers(self): 127 | # the accept headers should work even in case they're specified in a 128 | # resource method 129 | self.assertEqual( 130 | self.app.post( 131 | "/users", 132 | headers={"Accept": "application/json"}, 133 | params=json.dumps({"test": "yeah"}), 134 | ).json, 135 | {"test": "yeah"}, 136 | ) 137 | 138 | def patch(self, *args, **kwargs): 139 | return self.app._gen_request("PATCH", *args, **kwargs) 140 | 141 | def test_head_and_patch(self): 142 | self.app.head("/users") 143 | self.app.head("/users/1") 144 | 145 | self.assertEqual(self.patch("/users").json, {"test": "yeah"}) 146 | 147 | self.assertEqual(self.patch("/users/1").json, {"test": "yeah"}) 148 | 149 | def test_context_factory(self): 150 | self.assertEqual(self.app.put("/users/1").json, {"type": "context!"}) 151 | 152 | def test_explicit_collection_service_name(self): 153 | route_url = testing.DummyRequest().route_url 154 | # service must exist 155 | self.assertTrue(route_url("collection_user_service")) 156 | 157 | def test_explicit_service_name(self): 158 | route_url = testing.DummyRequest().route_url 159 | self.assertTrue(route_url("user_service", id=42)) # service must exist 160 | 161 | def test_acl_support_unauthenticated_thing_get(self): 162 | # calling a view with permissions without an auth'd user => 403 163 | self.app.get("/thing", status=HTTPForbidden.code) 164 | 165 | def test_acl_support_unauthenticated_forbidden_thing_get(self): 166 | # calling a view with permissions without an auth'd user => 403 167 | with mock.patch.object(self.authn_policy, "authenticated_userid", return_value=None): 168 | self.app.get("/thing", status=HTTPForbidden.code) 169 | 170 | def test_acl_support_authenticated_allowed_thing_get(self): 171 | with mock.patch.object(self.authn_policy, "unauthenticated_userid", return_value="alice"): 172 | with mock.patch.object( 173 | self.authn_policy, "authenticated_userid", return_value="alice" 174 | ): 175 | result = self.app.get("/thing", status=HTTPOk.code) 176 | self.assertEqual("yay", result.json) 177 | 178 | 179 | @pytest.mark.skip(reason="This test fails when ran with pytest, and it's too mysterious for now") 180 | class NonAutocommittingConfigurationTestResource(TestCase): 181 | """ 182 | Test that we don't fail Pyramid's conflict detection when using a manually- 183 | committing :class:`pyramid.config.Configurator` instance. 184 | """ 185 | 186 | def setUp(self): 187 | from pyramid.renderers import JSONP 188 | 189 | self.config = testing.setUp(autocommit=False) 190 | self.config.add_renderer("jsonp", JSONP(param_name="callback")) 191 | self.config.include("cornice") 192 | 193 | add_view(UserImp.get, renderer="json") 194 | # pyramid does not allow having 2 views with same request conditions 195 | add_view(UserImp.get, renderer="jsonp", accept="application/javascript") 196 | add_view(UserImp.collection_post, renderer="json", accept="application/json") 197 | user_resource = add_resource( 198 | UserImp, 199 | collection_path="/users", 200 | path="/users/{id}", 201 | name="user_service", 202 | factory=dummy_factory, 203 | ) 204 | 205 | self.config.add_cornice_resource(user_resource) 206 | self.app = TestApp(CatchErrors(self.config.make_wsgi_app())) 207 | 208 | def tearDown(self): 209 | testing.tearDown() 210 | 211 | def test_get(self): 212 | self.app.get("/users/1") 213 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 | # You can obtain one at http://mozilla.org/MPL/2.0/. 4 | from unittest import mock 5 | 6 | from pyramid import testing 7 | from webtest import TestApp 8 | 9 | from cornice import Service 10 | from cornice.pyramidhook import apply_filters 11 | 12 | from .support import CatchErrors, TestCase 13 | 14 | 15 | class TestCorniceSetup(TestCase): 16 | def setUp(self): 17 | self.config = testing.setUp() 18 | 19 | def _get_app(self): 20 | self.config.include("cornice") 21 | 22 | failing_service = Service(name="failing", path="/fail") 23 | failing_service.add_view("GET", lambda r: 1 / 0) 24 | self.config.add_cornice_service(failing_service) 25 | 26 | return TestApp(CatchErrors(self.config.make_wsgi_app())) 27 | 28 | def test_exception_handling_is_included_by_default(self): 29 | app = self._get_app() 30 | with mock.patch("cornice.pyramidhook.apply_filters", wraps=apply_filters) as mocked: 31 | app.post("/foo", status=404) 32 | self.assertTrue(mocked.called) 33 | 34 | def test_exception_handling_can_be_disabled(self): 35 | self.config.add_settings(handle_exceptions=False) 36 | app = self._get_app() 37 | with mock.patch("cornice.pyramidhook.apply_filters", wraps=apply_filters) as mocked: 38 | app.post("/foo", status=404) 39 | self.assertFalse(mocked.called) 40 | 41 | def test_exception_handling_raises_uncaught_errors(self): 42 | app = self._get_app() 43 | self.assertRaises(ZeroDivisionError, app.get, "/fail") 44 | -------------------------------------------------------------------------------- /tests/test_renderer.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from pyramid.interfaces import IJSONAdapter 4 | from pyramid.renderers import JSON 5 | from zope.interface import providedBy 6 | 7 | from cornice import CorniceRenderer 8 | from cornice.renderer import JSONError, bytes_adapter 9 | 10 | from .support import TestCase 11 | 12 | 13 | class TestBytesAdapter(TestCase): 14 | def test_with_bytes_object(self): 15 | self.assertEqual(bytes_adapter(b"hello", None), "hello") 16 | 17 | def test_with_string_object(self): 18 | self.assertEqual(bytes_adapter("hello", None), "hello") 19 | 20 | def test_with_incompatible_object(self): 21 | incompatible = object() 22 | self.assertEqual(bytes_adapter(incompatible, None), incompatible) 23 | 24 | 25 | class TestRenderer(TestCase): 26 | def test_renderer_is_pyramid_renderer_subclass(self): 27 | self.assertIsInstance(CorniceRenderer(), JSON) 28 | 29 | def test_renderer_has_bytes_adapter_by_default(self): 30 | renderer = CorniceRenderer() 31 | self.assertEqual( 32 | renderer.components.adapters.lookup((providedBy(bytes()),), IJSONAdapter), 33 | bytes_adapter, 34 | ) 35 | 36 | def test_renderer_calls_render_method(self): 37 | renderer = CorniceRenderer() 38 | self.assertEqual(renderer(info=None), renderer.render) 39 | 40 | def test_renderer_render_errors(self): 41 | renderer = CorniceRenderer() 42 | request = mock.MagicMock() 43 | 44 | class FakeErrors(object): 45 | status = 418 46 | 47 | def __json__(self, request): 48 | return ["error_1", "error_2"] 49 | 50 | request.errors = FakeErrors() 51 | 52 | result = renderer.render_errors(request) 53 | self.assertIsInstance(result, JSONError) 54 | self.assertEqual(result.status_int, 418) 55 | self.assertEqual(result.json_body, {"status": "error", "errors": ["error_1", "error_2"]}) 56 | -------------------------------------------------------------------------------- /tests/test_resource.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 | # You can obtain one at http://mozilla.org/MPL/2.0/. 4 | import functools 5 | import json 6 | from unittest import mock 7 | 8 | from pyramid import testing 9 | from pyramid.authentication import AuthTktAuthenticationPolicy 10 | from pyramid.authorization import ACLAuthorizationPolicy 11 | from pyramid.exceptions import ConfigurationError 12 | from pyramid.httpexceptions import HTTPForbidden, HTTPOk 13 | from pyramid.security import Allow 14 | from webtest import TestApp 15 | 16 | from cornice.resource import resource, view 17 | 18 | from .support import CatchErrors, TestCase, dummy_factory 19 | 20 | 21 | USERS = {1: {"name": "gawel"}, 2: {"name": "tarek"}} 22 | 23 | 24 | def my_collection_acl(request): 25 | return [(Allow, "alice", "read")] 26 | 27 | 28 | @resource(collection_path="/thing", path="/thing/{id}", name="thing_service") 29 | class Thing(object): 30 | """This is a thing.""" 31 | 32 | def __init__(self, request, context=None): 33 | self.request = request 34 | self.context = context 35 | 36 | def __acl__(self): 37 | return my_collection_acl(self.request) 38 | 39 | @view(permission="read") 40 | def collection_get(self): 41 | return "yay" 42 | 43 | 44 | @resource(collection_path="/users", path="/users/{id}", name="user_service", factory=dummy_factory) 45 | class User(object): 46 | """My user resource.""" 47 | 48 | def __init__(self, request, context=None): 49 | self.request = request 50 | self.context = context 51 | 52 | def collection_get(self): 53 | return {"users": list(USERS.keys())} 54 | 55 | @view(renderer="jsonp", accept="application/javascript") 56 | @view(renderer="json") 57 | def get(self): 58 | return USERS.get(int(self.request.matchdict["id"])) 59 | 60 | @view(renderer="json", accept="application/json") 61 | def collection_post(self): 62 | return {"test": "yeah"} 63 | 64 | def patch(self): 65 | return {"test": "yeah"} 66 | 67 | def collection_patch(self): 68 | return {"test": "yeah"} 69 | 70 | def put(self): 71 | return dict(type=repr(self.context)) 72 | 73 | 74 | class TestResourceWarning(TestCase): 75 | @mock.patch("warnings.warn") 76 | def test_path_clash(self, mocked_warn): 77 | @resource( 78 | collection_path="/badthing/{id}", path="/badthing/{id}", name="bad_thing_service" 79 | ) 80 | class BadThing(object): 81 | def __init__(self, request, context=None): 82 | pass 83 | 84 | msg = "Warning: collection_path and path are not distinct." 85 | mocked_warn.assert_called_with(msg) 86 | 87 | @mock.patch("warnings.warn") 88 | def test_routes_clash(self, mocked_warn): 89 | @resource( 90 | collection_pyramid_route="some_route", 91 | pyramid_route="some_route", 92 | name="bad_thing_service", 93 | ) 94 | class BadThing(object): 95 | def __init__(self, request, context=None): 96 | pass 97 | 98 | msg = "Warning: collection_pyramid_route and pyramid_route are not distinct." 99 | mocked_warn.assert_called_with(msg) 100 | 101 | def test_routes_with_paths(self): 102 | with self.assertRaises(ValueError): 103 | 104 | @resource( 105 | collection_path="/some_route", pyramid_route="some_route", name="bad_thing_service" 106 | ) 107 | class BadThing(object): 108 | def __init__(self, request, context=None): 109 | pass 110 | 111 | def test_routes_with_paths_reversed(self): 112 | with self.assertRaises(ValueError): 113 | 114 | @resource( 115 | collection_pyramid_route="some_route", path="/some_route", name="bad_thing_service" 116 | ) 117 | class BadThing(object): 118 | def __init__(self, request, context=None): 119 | pass 120 | 121 | 122 | class TestResource(TestCase): 123 | def setUp(self): 124 | from pyramid.renderers import JSONP 125 | 126 | self.config = testing.setUp() 127 | self.config.add_renderer("jsonp", JSONP(param_name="callback")) 128 | self.config.include("cornice") 129 | self.authz_policy = ACLAuthorizationPolicy() 130 | self.config.set_authorization_policy(self.authz_policy) 131 | 132 | self.authn_policy = AuthTktAuthenticationPolicy("$3kr1t") 133 | self.config.set_authentication_policy(self.authn_policy) 134 | self.config.scan("tests.test_resource") 135 | self.app = TestApp(CatchErrors(self.config.make_wsgi_app())) 136 | 137 | def tearDown(self): 138 | testing.tearDown() 139 | 140 | def test_basic_resource(self): 141 | self.assertEqual(self.app.get("/users").json, {"users": [1, 2]}) 142 | 143 | self.assertEqual(self.app.get("/users/1").json, {"name": "gawel"}) 144 | 145 | resp = self.app.get("/users/1?callback=test", headers={"Accept": "application/javascript"}) 146 | 147 | self.assertIn(b'test({"name": "gawel"})', resp.body, msg=resp.body) 148 | 149 | @mock.patch("cornice.resource.Service") 150 | def test_without_collection_path_has_one_service(self, mocked_service): 151 | @resource(path="/nocollection/{id}", name="nocollection") 152 | class NoCollection(object): 153 | def __init__(self, request, context=None): 154 | pass 155 | 156 | self.assertEqual(mocked_service.call_count, 1) 157 | 158 | def test_accept_headers(self): 159 | # the accept headers should work even in case they're specified in a 160 | # resource method 161 | self.assertEqual( 162 | self.app.post( 163 | "/users", 164 | headers={"Accept": "application/json"}, 165 | params=json.dumps({"test": "yeah"}), 166 | ).json, 167 | {"test": "yeah"}, 168 | ) 169 | 170 | def patch(self, *args, **kwargs): 171 | return self.app._gen_request("PATCH", *args, **kwargs) 172 | 173 | def test_head_and_patch(self): 174 | self.app.head("/users") 175 | self.app.head("/users/1") 176 | 177 | self.assertEqual(self.patch("/users").json, {"test": "yeah"}) 178 | 179 | self.assertEqual(self.patch("/users/1").json, {"test": "yeah"}) 180 | 181 | def test_context_factory(self): 182 | self.assertEqual(self.app.put("/users/1").json, {"type": "context!"}) 183 | 184 | def test_explicit_collection_service_name(self): 185 | route_url = testing.DummyRequest().route_url 186 | # service must exist 187 | self.assertTrue(route_url("collection_user_service")) 188 | 189 | def test_explicit_service_name(self): 190 | route_url = testing.DummyRequest().route_url 191 | self.assertTrue(route_url("user_service", id=42)) # service must exist 192 | 193 | @mock.patch("cornice.resource.Service") 194 | def test_factory_is_autowired(self, mocked_service): 195 | @resource(collection_path="/list", path="/list/{id}", name="list") 196 | class List(object): 197 | pass 198 | 199 | factory_args = [kw.get("factory") for _, kw in mocked_service.call_args_list] 200 | self.assertEqual([List, List], factory_args) 201 | 202 | def test_acl_is_deprecated(self): 203 | def custom_acl(request): 204 | return [] 205 | 206 | with self.assertRaises(ConfigurationError): 207 | 208 | @resource( 209 | collection_path="/list", 210 | path="/list/{id}", 211 | name="list", 212 | collection_acl=custom_acl, 213 | acl=custom_acl, 214 | ) 215 | class List(object): 216 | pass 217 | 218 | def test_acl_support_unauthenticated_thing_get(self): 219 | # calling a view with permissions without an auth'd user => 403 220 | self.app.get("/thing", status=HTTPForbidden.code) 221 | 222 | def test_acl_support_unauthenticated_forbidden_thing_get(self): 223 | # calling a view with permissions without an auth'd user => 403 224 | with mock.patch.object(self.authn_policy, "authenticated_userid", return_value=None): 225 | self.app.get("/thing", status=HTTPForbidden.code) 226 | 227 | def test_acl_support_authenticated_allowed_thing_get(self): 228 | with mock.patch.object(self.authn_policy, "unauthenticated_userid", return_value="alice"): 229 | with mock.patch.object( 230 | self.authn_policy, "authenticated_userid", return_value="alice" 231 | ): 232 | result = self.app.get("/thing", status=HTTPOk.code) 233 | self.assertEqual("yay", result.json) 234 | 235 | def test_service_wrapped_resource(self): 236 | resources = { 237 | "thing_service": Thing, 238 | "user_service": User, 239 | } 240 | 241 | for name, service in ( 242 | (x.name, x) 243 | for x in self.config.registry.cornice_services.values() 244 | if x.name in resources 245 | ): 246 | for attr in functools.WRAPPER_ASSIGNMENTS: 247 | with self.subTest(service=name, attribute=attr): 248 | self.assertEqual( 249 | getattr(resources[name], attr, None), getattr(service, attr, None) 250 | ) 251 | 252 | 253 | class NonAutocommittingConfigurationTestResource(TestCase): 254 | """ 255 | Test that we don't fail Pyramid's conflict detection when using a manually- 256 | committing :class:`pyramid.config.Configurator` instance. 257 | """ 258 | 259 | def setUp(self): 260 | from pyramid.renderers import JSONP 261 | 262 | self.config = testing.setUp(autocommit=False) 263 | self.config.add_renderer("jsonp", JSONP(param_name="callback")) 264 | self.config.include("cornice") 265 | self.config.scan("tests.test_resource") 266 | self.app = TestApp(CatchErrors(self.config.make_wsgi_app())) 267 | 268 | def tearDown(self): 269 | testing.tearDown() 270 | 271 | def test_get(self): 272 | self.app.get("/users/1") 273 | -------------------------------------------------------------------------------- /tests/test_resource_callable.py: -------------------------------------------------------------------------------- 1 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 2 | # You can obtain one at http://mozilla.org/MPL/2.0/. 3 | 4 | import json 5 | 6 | from pyramid import testing 7 | from webtest import TestApp 8 | 9 | from cornice.resource import resource, view 10 | 11 | from .support import CatchErrors, TestCase 12 | 13 | 14 | FRUITS = {1: {"name": "apple"}, 2: {"name": "orange"}} 15 | 16 | 17 | def _accept(request): 18 | return ("text/plain", "application/json") 19 | 20 | 21 | def _content_type(request): 22 | return ("text/plain", "application/json") 23 | 24 | 25 | @resource(collection_path="/fruits", path="/fruits/{id}", name="fruit_service", accept=_accept) 26 | class Fruit(object): 27 | def __init__(self, request, context=None): 28 | self.request = request 29 | self.context = context 30 | 31 | def collection_get(self): 32 | return {"fruits": list(FRUITS.keys())} 33 | 34 | @view(renderer="json", accept=_accept) 35 | def get(self): 36 | return FRUITS.get(int(self.request.matchdict["id"])) 37 | 38 | @view(renderer="json", accept=_accept, content_type=_content_type) 39 | def collection_post(self): 40 | return {"test": "yeah"} 41 | 42 | 43 | class TestResource(TestCase): 44 | def setUp(self): 45 | from pyramid.renderers import JSONP 46 | 47 | self.config = testing.setUp() 48 | self.config.add_renderer("jsonp", JSONP(param_name="callback")) 49 | self.config.include("cornice") 50 | 51 | self.config.scan("tests.test_resource_callable") 52 | self.app = TestApp(CatchErrors(self.config.make_wsgi_app())) 53 | 54 | def tearDown(self): 55 | testing.tearDown() 56 | 57 | def test_accept_headers_get(self): 58 | self.assertEqual( 59 | self.app.get("/fruits", headers={"Accept": "text/plain"}).body, b'{"fruits": [1, 2]}' 60 | ) 61 | 62 | self.assertEqual( 63 | self.app.get("/fruits", headers={"Accept": "application/json"}).json, 64 | {"fruits": [1, 2]}, 65 | ) 66 | 67 | self.assertEqual( 68 | self.app.get("/fruits/1", headers={"Accept": "text/plain"}).json, {"name": "apple"} 69 | ) 70 | 71 | self.assertEqual( 72 | self.app.get("/fruits/1", headers={"Accept": "application/json"}).json, 73 | {"name": "apple"}, 74 | ) 75 | 76 | def test_accept_headers_post(self): 77 | self.assertEqual( 78 | self.app.post( 79 | "/fruits", 80 | headers={"Accept": "text/plain", "Content-Type": "application/json"}, 81 | params=json.dumps({"test": "yeah"}), 82 | ).json, 83 | {"test": "yeah"}, 84 | ) 85 | 86 | self.assertEqual( 87 | self.app.post( 88 | "/fruits", 89 | headers={"Accept": "application/json", "Content-Type": "application/json"}, 90 | params=json.dumps({"test": "yeah"}), 91 | ).json, 92 | {"test": "yeah"}, 93 | ) 94 | 95 | def test_406(self): 96 | self.app.get("/fruits", headers={"Accept": "text/xml"}, status=406) 97 | 98 | self.app.post( 99 | "/fruits", 100 | headers={"Accept": "text/html"}, 101 | params=json.dumps({"test": "yeah"}), 102 | status=406, 103 | ) 104 | 105 | def test_415(self): 106 | self.app.post( 107 | "/fruits", 108 | headers={"Accept": "application/json", "Content-Type": "text/html"}, 109 | status=415, 110 | ) 111 | -------------------------------------------------------------------------------- /tests/test_resource_custom_predicates.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 | # You can obtain one at http://mozilla.org/MPL/2.0/. 4 | from pyramid import testing 5 | from pyramid.authentication import AuthTktAuthenticationPolicy 6 | from pyramid.authorization import ACLAuthorizationPolicy 7 | from webtest import TestApp 8 | 9 | from cornice.resource import resource, view 10 | 11 | from .support import CatchErrors, TestCase 12 | 13 | 14 | class employeeType(object): 15 | def __init__(self, val, config): 16 | self.val = val 17 | 18 | def text(self): 19 | return "position = %s" % (self.val,) 20 | 21 | phash = text 22 | 23 | def __call__(self, context, request): 24 | if request.params.get("position") is not None: 25 | position = request.params.get("position") 26 | return position == self.val 27 | return False 28 | 29 | 30 | @resource( 31 | collection_path="/company/employees", 32 | path="/company/employees/{id}", 33 | name="Topmanagers", 34 | position="topmanager", 35 | ) 36 | class EManager(object): 37 | def __init__(self, request, context=None): 38 | self.request = request 39 | self.context = context 40 | 41 | @view(renderer="json", accept="application/json") 42 | def collection_get(self): 43 | return ["Topmanagers list get"] 44 | 45 | @view(renderer="json", accept="application/json") 46 | def get(self): 47 | return {"get": "Topmanagers"} 48 | 49 | @view(renderer="json", accept="application/json") 50 | def collection_post(self): 51 | return ["Topmanagers list post"] 52 | 53 | @view(renderer="json", accept="application/json") 54 | def patch(self): 55 | return {"patch": "Topmanagers"} 56 | 57 | @view(renderer="json", accept="application/json") 58 | def put(self): 59 | return {"put": "Topmanagers"} 60 | 61 | 62 | @resource( 63 | collection_path="/company/employees", 64 | path="/company/employees/{id}", 65 | name="Supervisors", 66 | position="supervisor", 67 | ) 68 | class ESupervisor(object): 69 | def __init__(self, request, context=None): 70 | self.request = request 71 | self.context = context 72 | 73 | @view(renderer="json", accept="application/json") 74 | def collection_get(self): 75 | return ["Supervisors list get"] 76 | 77 | @view(renderer="json", accept="application/json") 78 | def get(self): 79 | return {"get": "Supervisors"} 80 | 81 | @view(renderer="json", accept="application/json") 82 | def collection_post(self): 83 | return ["Supervisors list post"] 84 | 85 | @view(renderer="json", accept="application/json") 86 | def patch(self): 87 | return {"patch": "Supervisors"} 88 | 89 | @view(renderer="json", accept="application/json") 90 | def put(self): 91 | return {"put": "Supervisors"} 92 | 93 | 94 | class TestCustomPredicates(TestCase): 95 | def setUp(self): 96 | from pyramid.renderers import JSONP 97 | 98 | self.config = testing.setUp() 99 | self.config.add_renderer("jsonp", JSONP(param_name="callback")) 100 | self.config.include("cornice") 101 | self.authz_policy = ACLAuthorizationPolicy() 102 | self.config.set_authorization_policy(self.authz_policy) 103 | 104 | self.authn_policy = AuthTktAuthenticationPolicy("$3kr1t") 105 | self.config.set_authentication_policy(self.authn_policy) 106 | self.config.add_route_predicate("position", employeeType) 107 | self.config.scan("tests.test_resource_custom_predicates") 108 | self.app = TestApp(CatchErrors(self.config.make_wsgi_app())) 109 | 110 | def tearDown(self): 111 | testing.tearDown() 112 | 113 | def test_get_resource_predicates(self): 114 | # Tests for resource with name 'Supervisors' 115 | res = self.app.get("/company/employees?position=supervisor").json 116 | self.assertEqual(res[0], "Supervisors list get") 117 | res = self.app.get("/company/employees/2?position=supervisor").json 118 | self.assertEqual(res["get"], "Supervisors") 119 | 120 | # Tests for resource with name 'Topmanagers' 121 | res = self.app.get("/company/employees?position=topmanager").json 122 | self.assertEqual(res[0], "Topmanagers list get") 123 | res = self.app.get("/company/employees/1?position=topmanager").json 124 | self.assertEqual(res["get"], "Topmanagers") 125 | 126 | def test_post_resource_predicates(self): 127 | # Tests for resource with name 'Supervisors' 128 | supervisor_data = {"name": "Jimmy Arrow", "position": "supervisor", "salary": 50000} 129 | res = self.app.post("/company/employees", supervisor_data).json 130 | self.assertEqual(res[0], "Supervisors list post") 131 | 132 | # Tests for resource with name 'Topmanagers' 133 | topmanager_data = {"name": "Jimmy Arrow", "position": "topmanager", "salary": 30000} 134 | res = self.app.post("/company/employees", topmanager_data).json 135 | self.assertEqual(res[0], "Topmanagers list post") 136 | 137 | def test_patch_resource_predicates(self): 138 | # Tests for resource with name 'Supervisors' 139 | res = self.app.patch("/company/employees/2?position=supervisor", {"salary": 1001}).json 140 | self.assertEqual(res["patch"], "Supervisors") 141 | 142 | # Tests for resource with name 'Topmanagers' 143 | res = self.app.patch("/company/employees/1?position=topmanager", {"salary": 2002}).json 144 | self.assertEqual(res["patch"], "Topmanagers") 145 | 146 | def test_put_resource_predicates(self): 147 | # Tests for resource with name 'Supervisors' 148 | supervisor_data = {"position": "supervisor", "salary": 53000} 149 | res = self.app.put("/company/employees/2", supervisor_data).json 150 | self.assertEqual(res["put"], "Supervisors") 151 | 152 | # Tests for resource with name 'Topmanagers' 153 | topmanager_data = {"position": "topmanager", "salary": 33000} 154 | res = self.app.put("/company/employees/1", topmanager_data).json 155 | self.assertEqual(res["put"], "Topmanagers") 156 | -------------------------------------------------------------------------------- /tests/test_resource_traverse.py: -------------------------------------------------------------------------------- 1 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 2 | # You can obtain one at http://mozilla.org/MPL/2.0/. 3 | 4 | from pyramid import testing 5 | from webtest import TestApp 6 | 7 | from cornice.resource import resource, view 8 | 9 | from .support import CatchErrors, TestCase 10 | 11 | 12 | FRUITS = {"1": {"name": "apple"}, "2": {"name": "orange"}} 13 | 14 | 15 | class FruitFactory(object): 16 | def __init__(self, request): 17 | self.request = request 18 | 19 | def __getitem__(self, key): 20 | return FRUITS[key] 21 | 22 | 23 | @resource( 24 | collection_path="/fruits/", 25 | collection_factory=FruitFactory, 26 | collection_traverse="", 27 | path="/fruits/{fruit_id}/", 28 | factory=FruitFactory, 29 | name="fruit_service", 30 | traverse="/{fruit_id}", 31 | ) 32 | class Fruit(object): 33 | def __init__(self, request, context): 34 | self.request = request 35 | self.context = context 36 | 37 | def collection_get(self): 38 | return {"fruits": list(FRUITS.keys())} 39 | 40 | @view(renderer="json") 41 | def get(self): 42 | return self.context 43 | 44 | 45 | class TestResourceTraverse(TestCase): 46 | def setUp(self): 47 | self.config = testing.setUp() 48 | self.config.include("cornice") 49 | 50 | self.config.scan("tests.test_resource_traverse") 51 | self.app = TestApp(CatchErrors(self.config.make_wsgi_app())) 52 | 53 | def tearDown(self): 54 | testing.tearDown() 55 | 56 | def test_collection_traverse(self): 57 | resp = self.app.get("/fruits/").json 58 | self.assertEqual(sorted(resp["fruits"]), ["1", "2"]) 59 | 60 | def test_traverse(self): 61 | resp = self.app.get("/fruits/1/") 62 | self.assertEqual(resp.json, {"name": "apple"}) 63 | 64 | resp = self.app.get("/fruits/2/") 65 | self.assertEqual(resp.json, {"name": "orange"}) 66 | 67 | self.app.get("/fruits/3/", status=404) 68 | -------------------------------------------------------------------------------- /tests/test_service_definition.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 | # You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | from pyramid import testing 6 | from webtest import TestApp 7 | 8 | from cornice import Service 9 | 10 | from .support import CatchErrors, TestCase 11 | 12 | 13 | service1 = Service(name="service1", path="/service1") 14 | service2 = Service(name="service2", path="/service2") 15 | 16 | 17 | @service1.get() 18 | def get1(request): 19 | return {"test": "succeeded"} 20 | 21 | 22 | @service1.post() 23 | def post1(request): 24 | return {"body": request.body} 25 | 26 | 27 | @service2.get(accept="text/html") 28 | @service2.post(accept="audio/ogg") 29 | def get2_or_post2(request): 30 | return {"test": "succeeded"} 31 | 32 | 33 | class TestServiceDefinition(TestCase): 34 | def setUp(self): 35 | self.config = testing.setUp() 36 | self.config.include("cornice") 37 | self.config.scan("tests.test_service_definition") 38 | self.app = TestApp(CatchErrors(self.config.make_wsgi_app())) 39 | 40 | def tearDown(self): 41 | testing.tearDown() 42 | 43 | def test_basic_service_operation(self): 44 | self.app.get("/unknown", status=404) 45 | self.assertEqual(self.app.get("/service1").json, {"test": "succeeded"}) 46 | 47 | self.assertEqual(self.app.post("/service1", params="BODY").json, {"body": "BODY"}) 48 | 49 | def test_loading_into_multiple_configurators(self): 50 | # When initializing a second configurator, it shouldn't interfere 51 | # with the one already in place. 52 | config2 = testing.setUp() 53 | config2.include("cornice") 54 | config2.scan("tests.test_service_definition") 55 | 56 | # Calling the new configurator works as expected. 57 | app = TestApp(CatchErrors(config2.make_wsgi_app())) 58 | self.assertEqual(app.get("/service1").json, {"test": "succeeded"}) 59 | 60 | # Calling the old configurator works as expected. 61 | self.assertEqual(self.app.get("/service1").json, {"test": "succeeded"}) 62 | 63 | def test_stacking_api_decorators(self): 64 | # Stacking multiple @api calls on a single function should 65 | # register it multiple times, just like @view_config does. 66 | resp = self.app.get("/service2", headers={"Accept": "text/html"}) 67 | self.assertEqual(resp.json, {"test": "succeeded"}) 68 | 69 | resp = self.app.post("/service2", headers={"Accept": "audio/ogg"}) 70 | self.assertEqual(resp.json, {"test": "succeeded"}) 71 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | import unittest 3 | from unittest import mock 4 | 5 | from cornice import util 6 | 7 | 8 | class TestDeprecatedUtils(unittest.TestCase): 9 | def test_extract_json_data_is_deprecated(self): 10 | with mock.patch("cornice.util.warnings") as mocked: 11 | util.extract_json_data(mock.MagicMock()) 12 | self.assertTrue(mocked.warn.called) 13 | 14 | def test_extract_form_urlencoded_data_is_deprecated(self): 15 | with mock.patch("cornice.util.warnings") as mocked: 16 | util.extract_form_urlencoded_data(mock.MagicMock()) 17 | self.assertTrue(mocked.warn.called) 18 | 19 | 20 | class CurrentServiceTest(unittest.TestCase): 21 | def test_current_service_returns_the_service_for_existing_patterns(self): 22 | request = mock.MagicMock() 23 | request.matched_route.pattern = "/buckets" 24 | request.registry.cornice_services = {"/buckets": mock.sentinel.service} 25 | 26 | self.assertEqual(util.current_service(request), mock.sentinel.service) 27 | 28 | def test_current_service_returns_none_for_unexisting_patterns(self): 29 | request = mock.MagicMock() 30 | request.matched_route.pattern = "/unexisting" 31 | request.registry.cornice_services = {} 32 | 33 | self.assertEqual(util.current_service(request), None) 34 | --------------------------------------------------------------------------------