diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index 9b23c028..adaf3406 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -26,9 +26,9 @@ jobs: restore-keys: | ${{ runner.os }}-pip- - # install black + # install black (extract version from versions.cfg) - name: install black - run: pip install black + run: pip install click==8.0.4 black==$(awk '/^black =/{print $NF}' versions.cfg) # run black - name: run black diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0c560a38..86225558 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,4 +1,4 @@ -name: plone.rest CI +name: Tests on: [push] jobs: build: @@ -6,27 +6,27 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.8, 3.7, 2.7] - plone-version: [5.2, 5.1, 4.3] + python-version: ["3.12", "3.11", "3.10", "3.9", "3.8"] + plone-version: ["6.0", "5.2"] exclude: - - python-version: 3.7 - plone-version: 4.3 - - python-version: 3.7 - plone-version: 5.1 - - python-version: 3.8 - plone-version: 4.3 - - python-version: 3.8 - plone-version: 5.1 + - python-version: 3.12 + plone-version: 5.2 + - python-version: 3.11 + plone-version: 5.2 + - python-version: 3.10 + plone-version: 5.2 + - python-version: 3.9 + plone-version: 5.2 steps: - # git checkout - uses: actions/checkout@v2 # python setup - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + cache: "pip" # python cache - uses: actions/cache@v1 @@ -40,13 +40,11 @@ jobs: - run: pip install virtualenv - run: pip install wheel - name: pip install - run: pip install -r requirements.txt - - name: choose Plone version - run: sed -ie "s#plone-x.x.x.cfg#plone-${{ matrix.plone-version }}.x.cfg#" ci.cfg + run: pip install -r requirements-${{ matrix.plone-version }}.txt # buildout - name: buildout - run: buildout -t 10 -c ci.cfg + run: buildout -t 10 -c plone-${{ matrix.plone-version }}.x.cfg env: CI: true diff --git a/.gitignore b/.gitignore index 30ab005f..ece0007b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ /.Python /include /lib +/lib64 /.mr.developer.cfg *.mo local/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..621be3eb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,38 @@ +# Generated from: +# https://github.com/plone/meta/tree/master/config/default +ci: + autofix_prs: false + autoupdate_schedule: monthly + +repos: + - repo: https://github.com/asottile/pyupgrade + rev: v3.14.0 + hooks: + - id: pyupgrade + args: [--py38-plus] + - repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + - repo: https://github.com/psf/black + rev: 23.9.1 + hooks: + - id: black + - repo: https://github.com/PyCQA/flake8 + rev: 6.1.0 + hooks: + - id: flake8 + - repo: https://github.com/codespell-project/codespell + rev: v2.2.6 + hooks: + - id: codespell + additional_dependencies: + - tomli + - repo: https://github.com/mgedmin/check-manifest + rev: "0.49" + hooks: + - id: check-manifest + - repo: https://github.com/regebro/pyroma + rev: "4.2" + hooks: + - id: pyroma diff --git a/CHANGES.rst b/CHANGES.rst index 3cf81ce6..85528537 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,6 +8,124 @@ Changelog .. towncrier release notes start +4.1.1 (2023-10-23) +------------------ + +Internal: + + +- Fix test leakage, enabling the publication check when it shouldn't be active. @jaroel (#168) + + +4.1.0 (2023-10-18) +------------------ + +New features: + + +- Add support for Python 3.12. @tisto (#167) + + +Bug fixes: + + +- Make REST endpoints check for acquired items. @jaroel (#166) + + +4.0.0 (2023-09-22) +------------------ + +Breaking changes: + + +- Drop support for Python 2.7, 3.6, and 3.7 @tisto (#141) + + +3.0.1 (2023-09-21) +------------------ + +Bug fixes: + + +- When ``++api++`` is in the url multiple times, redirect to the proper url. + When the url is badly formed, for example ``++api++/something/++api++``, give a 404 NotFound. + Fixes a denial of service. + See `security advisory `_. + [maurits] (#1) + + +3.0.0 (2023-01-29) +------------------ + +Breaking changes: + + +- Change the HTTP status from 301 (Moved Permanently) to 302 (Found) for GET requests and to 307 (Temporary Redirect) for other request methods. + This fixes problems when an existing redirect is re-used. + [mamico] (#135) +- Drop official support for Plone 4.3, 5.0 and 5.1 (most likely the package will continue to work though) + [tisto] (#140) + + +New features: + + +- Add official support for Plone 6 + [tisto] (#143) +- Add official support for Python 3.9, 3.10, and 3.11 + [tisto] (#147) + + +2.0.0 (2022-10-15) +------------------ + +Bug fixes: + + +- Re-release 2.0.0a6 as 2.0.0 [tisto] (#136) + + +2.0.0a5 (2022-04-07) +-------------------- + +Bug fixes: + + +- Fix an infinite loop with redirections from parent to child [ericof] (#133) + + +2.0.0a4 (2022-03-24) +-------------------- + +Bug fixes: + + +- ++api++ traverser should be kept on 30x redirections [mamico] (#132) + + +2.0.0a3 (2022-02-12) +-------------------- + +Bug fixes: + + +- ++api++ traverser should be kept on 30x redirections [mamico] (#127) + + +2.0.0a2 (2022-01-25) +-------------------- + +Bug fixes: + + +- Fix typo in `README.rst` [jensens] (#123) +- Use document_view as default for site root. + [agitator] (#126) +- Resolve all the deprecation warnings that originate in this package's code that are + exposed by running the tests that do not stem from backwards compatibility we support. + [rpatterson] (#128) + + 2.0.0a1 (2021-10-05) -------------------- @@ -151,7 +269,7 @@ Bugfixes: [buchi] - Fallback to regular views during traversal to ensure compatibility with - views beeing called with a specific Accept header. + views being called with a specific Accept header. [buchi] @@ -201,7 +319,7 @@ Bugfixes: - Refactor traversal of REST requests by using a traversal adapter on the site root instead of a traversal adapter for each REST service. This prevents - REST services from being overriden by other traversal adapters. + REST services from being overridden by other traversal adapters. [buchi] diff --git a/MANIFEST.in b/MANIFEST.in index bbb2eef8..79842040 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,7 +6,9 @@ exclude .flake8 exclude bootstrap-buildout.py exclude Makefile exclude requirements.txt +exclude requirements-*.txt exclude CODEOWNERS +exclude .pre-commit-config.yaml global-exclude *.pyc include pyproject.toml recursive-exclude news * diff --git a/Makefile b/Makefile index 78f6e9d7..2f903af0 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ SHELL := /bin/bash CURRENT_DIR:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) -version = 3 +version = 3.9 # We like colors # From: https://coderwall.com/p/izxssa/colored-makefile-for-golang-projects @@ -12,7 +12,7 @@ GREEN=`tput setaf 2` RESET=`tput sgr0` YELLOW=`tput setaf 3` -all: .installed.cfg +all: build-plone-6.0 # Add the following 'help' target to your Makefile # And add help text after each target name starting with '\#\#' @@ -36,7 +36,7 @@ update: ## Update Make and Buildout bin/buildout: bin/pip bin/pip install --upgrade pip bin/pip install -r requirements.txt - bin/pip install black || true + bin/pip install pip install black==$$(awk '/^black =/{print $$NF}' versions.cfg) @touch -c $@ bin/python bin/pip: @@ -83,6 +83,13 @@ build-plone-5.2-performance: .installed.cfg ## Build Plone 5.2 bin/pip install -r requirements.txt bin/buildout -c plone-5.2.x-performance.cfg +build-plone-6.0: ## Build Plone 6.0 + python$(version) -m venv . + bin/pip install --upgrade pip + bin/pip install -r requirements-6.0.txt + bin/pip install pip install black==$$(awk '/^black =/{print $$NF}' versions.cfg) + bin/buildout -c plone-6.0.x.cfg + .PHONY: Test test: ## Test bin/test diff --git a/README.rst b/README.rst index 4b91180f..f6923ebf 100644 --- a/README.rst +++ b/README.rst @@ -1,21 +1,21 @@ -.. image:: https://github.com/plone/plone.rest/workflows/plone.rest%20CI/badge.svg +.. image:: https://github.com/plone/plone.rest/actions/workflows/tests.yml/badge.svg :alt: Github Actions Status - :target: https://github.com/plone/plone.rest/actions?query=workflow%3A%22plone.rest+CI%22 + :target: https://github.com/plone/plone.rest/actions/workflows/tests.yml .. image:: https://img.shields.io/coveralls/github/plone/plone.rest.svg :alt: Coveralls github :target: https://coveralls.io/github/plone/plone.restapi .. image:: https://img.shields.io/pypi/status/plone.rest.svg - :target: https://pypi.python.org/pypi/plone.rest/ + :target: https://pypi.org/project/plone.rest/ :alt: Egg Status .. image:: https://img.shields.io/pypi/v/plone.rest.svg - :target: https://pypi.python.org/pypi/plone.rest/ + :target: https://pypi.org/project/plone.rest/ :alt: Latest Version .. image:: https://img.shields.io/pypi/l/plone.rest.svg - :target: https://pypi.python.org/pypi/plone.rest/ + :target: https://pypi.org/project/plone.rest/ :alt: License @@ -33,7 +33,7 @@ It is a software architectural principle to create loosely coupled web APIs. plone.rest provides the basic infrastructure that allows us to build RESTful endpoints in Plone. -The reason for separating this infrastructure into a separate package from the 'main' full `Plone REST API `_ is so you can create alternative endpoints tailored to specific usecases. +The reason for separating this infrastructure into a separate package from the 'main' full `Plone REST API `_ is so you can create alternative endpoints tailored to specific usecases. A number of these specific endpoints are already in active use. @@ -70,7 +70,7 @@ plone.rest allows you to register HTTP verbs for Plone content with ZCML. This is how you would register a PATCH request on Dexterity content: -.. code-block:: xml +.. code-block:: XML and Ramon Navarro Bosch . +This package is maintained by Timo Stollenwerk . If you are having issues, please `let us know `_. +Credits +------- + +plone.rest has been written by Timo Stollenwerk (`kitconcept GmbH `_) and Ramon Navarro Bosch (`Iskra `_). + +plone.rest was added as a Plone core package with Plone 5.2 (see ``_). + + License ------- diff --git a/base.cfg b/base.cfg index 10182f54..152d0905 100644 --- a/base.cfg +++ b/base.cfg @@ -1,5 +1,5 @@ [buildout] -index = https://pypi.python.org/simple +index = https://pypi.org/simple extensions = mr.developer parts = instance @@ -61,5 +61,5 @@ eggs = [sources] plone.dexterity = git git://github.com/plone/plone.dexterity.git pushurl=git@github.com:plone/plone.dexterity.git branch=plip-680 -plone.rest = git git://github.com/plone/plone.rest.git pushurl=git@github.com:plone/plone.rest.git branch=master +plone.rest = git git://github.com/plone/plone.rest.git pushurl=git@github.com:plone/plone.rest.git branch=main Products.CMFPlone = git git://github.com/plone/Products.CMFPlone.git pushurl=git@github.com:plone/Products.CMFPlone.git branch=4.3.x-plip-680 diff --git a/ci.cfg b/ci.cfg deleted file mode 100644 index af1a4f24..00000000 --- a/ci.cfg +++ /dev/null @@ -1,7 +0,0 @@ -[buildout] -extends = plone-x.x.x.cfg - -[code-analysis] -recipe = plone.recipe.codeanalysis -pre-commit-hook = False -return-status-codes = True diff --git a/news/153.bugfix b/news/153.bugfix new file mode 100644 index 00000000..152666d3 --- /dev/null +++ b/news/153.bugfix @@ -0,0 +1 @@ +Fix parsing mimetypes in Accept header with an extra slash. @djay diff --git a/plone-4.3.x.cfg b/plone-4.3.x.cfg index 858527ff..72674c4e 100644 --- a/plone-4.3.x.cfg +++ b/plone-4.3.x.cfg @@ -26,6 +26,9 @@ distlib = 0.3.1 [versions:python27] PyJWT = 1.7.1 pyroma = 2.6.1 +pep517 = <=0.12.0 +readme-renderer = <=28.0 +bleach = <4 # more-itertools >= 6.0.0 dropped python2.7 support more-itertools = 5.0.0 diff --git a/plone-5.1.x.cfg b/plone-5.1.x.cfg index 33ccdcca..9729141e 100644 --- a/plone-5.1.x.cfg +++ b/plone-5.1.x.cfg @@ -37,6 +37,9 @@ astunparse = 1.6.2 [versions:python27] PyJWT = 1.7.1 pyroma = 2.6.1 +pep517 = <=0.12.0 +readme-renderer = <=28.0 +bleach = <4 # more-itertools >= 6.0.0 dropped python2.7 support more-itertools = 5.0.0 @@ -45,4 +48,4 @@ more-itertools = 5.0.0 pyrsistent = 0.15.7 # Click 8 dropped Python 2 support -Click = 7.1.2 \ No newline at end of file +Click = 7.1.2 diff --git a/plone-5.2.x.cfg b/plone-5.2.x.cfg index f4d814f8..3eca6c94 100644 --- a/plone-5.2.x.cfg +++ b/plone-5.2.x.cfg @@ -1,12 +1,13 @@ [buildout] extends = base.cfg - https://dist.plone.org/release/5.2.4/versions.cfg + https://dist.plone.org/release/5.2.7/versions.cfg find-links += https://dist.plone.org/thirdparty/ versions=versions [versions] -black = 20.8b1 +plone.rest = +black = 23.3.0 # Error: The requirement ('virtualenv>=20.0.35') is not allowed by your [versions] constraint (20.0.26) virtualenv = 20.0.35 diff --git a/plone-6.0.x.cfg b/plone-6.0.x.cfg new file mode 100644 index 00000000..dfc2e44f --- /dev/null +++ b/plone-6.0.x.cfg @@ -0,0 +1,20 @@ +[buildout] +extends = + https://dist.plone.org/release/6.0.7/versions.cfg + base.cfg + +[instance] +recipe = plone.recipe.zope2instance +zodb-temporary-storage = off + +[versions] +plone.rest = +black = 23.3.0 +pygments = 2.14.0 + +# all this is necessary to make the p.a.contenttypes (robotframework) test dependency happy :( +robotframework-browser = 17.5.2 +robotframework-assertion-engine = 2.0.0 +robotframework-debuglibrary = 2.3.0 +robotframework-pythonlibcore = 4.2.0 +grpcio-tools = 1.59.0 diff --git a/pyproject.toml b/pyproject.toml index 05b615de..a9ead58a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,3 +18,14 @@ showcontent = true directory = "bugfix" name = "Bug fixes:" showcontent = true + +[[tool.towncrier.type]] +directory = "internal" +name = "Internal:" +showcontent = true + +[tool.isort] +profile = "plone" + +[tool.black] +target-version = ["py38"] \ No newline at end of file diff --git a/requirements-5.2.txt b/requirements-5.2.txt new file mode 100644 index 00000000..505c1abe --- /dev/null +++ b/requirements-5.2.txt @@ -0,0 +1,12 @@ +# Keep this file in sync with: https://dist.plone.org/release/5.2.9/requirements.txt +setuptools==42.0.2 +zc.buildout==2.13.7 +wheel==0.37.1 + +# Windows specific down here (has to be installed here, fails in buildout) +# Dependency of zope.sendmail: +pywin32 ; platform_system == 'Windows' +# SSL Certs on Windows, because Python is missing them otherwise: +certifi ; platform_system == 'Windows' +# Dependency of collective.recipe.omelette: +ntfsutils ; platform_system == 'Windows' and python_version < '3.0' \ No newline at end of file diff --git a/requirements-6.0.txt b/requirements-6.0.txt new file mode 100644 index 00000000..dcd3abcf --- /dev/null +++ b/requirements-6.0.txt @@ -0,0 +1 @@ +-r https://dist.plone.org/release/6.0.7/requirements.txt diff --git a/requirements.txt b/requirements.txt index 8466fed9..0c1a2280 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ # Keep this file in sync with: https://github.com/kitconcept/buildout/edit/master/requirements.txt setuptools==42.0.2 -zc.buildout==2.13.3 +zc.buildout==2.13.4 wheel diff --git a/setup.py b/setup.py index a1e0304c..4e3df0b8 100644 --- a/setup.py +++ b/setup.py @@ -1,12 +1,14 @@ +from setuptools import find_packages +from setuptools import setup + import os -from setuptools import setup, find_packages def read(*rnames): return open(os.path.join(os.path.dirname(__file__), *rnames)).read() -version = "2.0.0a2.dev0" +version = "4.1.2.dev0" long_description = read("README.rst") + "\n\n" + read("CHANGES.rst") + "\n\n" @@ -22,20 +24,21 @@ def read(*rnames): "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Plone", - "Framework :: Plone :: 4.3", - "Framework :: Plone :: 5.0", - "Framework :: Plone :: 5.1", "Framework :: Plone :: 5.2", + "Framework :: Plone :: 6.0", "Framework :: Plone :: Core", "Framework :: Zope2", "Framework :: Zope :: 4", "License :: OSI Approved :: GNU General Public License (GPL)", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Software Development :: Libraries :: Python Modules", ], keywords="rest http", author="Plone Foundation", @@ -47,6 +50,7 @@ def read(*rnames): namespace_packages=["plone"], include_package_data=True, zip_safe=False, + python_requires=">=3.8", extras_require=dict( test=[ "plone.app.testing[robot]>=4.2.2", diff --git a/src/plone/rest/__init__.py b/src/plone/rest/__init__.py index 44646e48..ab37f224 100644 --- a/src/plone/rest/__init__.py +++ b/src/plone/rest/__init__.py @@ -1,2 +1 @@ -# -*- coding: utf-8 -*- from plone.rest.service import Service # noqa diff --git a/src/plone/rest/configure.zcml b/src/plone/rest/configure.zcml index f03a0e85..af6c1153 100644 --- a/src/plone/rest/configure.zcml +++ b/src/plone/rest/configure.zcml @@ -26,4 +26,8 @@ provides="zope.interface.Interface" /> + + diff --git a/src/plone/rest/cors.py b/src/plone/rest/cors.py index dbc2035b..737bed84 100644 --- a/src/plone/rest/cors.py +++ b/src/plone/rest/cors.py @@ -1,7 +1,7 @@ -# -*- coding: utf-8 -*- from plone.rest.interfaces import ICORSPolicy from zope.interface import implementer + # CORS preflight service registry # A mapping of method -> service_id _services = {} @@ -19,7 +19,7 @@ def lookup_preflight_service_id(method): @implementer(ICORSPolicy) -class CORSPolicy(object): +class CORSPolicy: def __init__(self, context, request): self.context = context self.request = request diff --git a/src/plone/rest/demo.py b/src/plone/rest/demo.py index 4622f978..4a3bcf57 100644 --- a/src/plone/rest/demo.py +++ b/src/plone/rest/demo.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from plone.rest import Service import json diff --git a/src/plone/rest/errors.py b/src/plone/rest/errors.py index 8227ff40..d6a9c536 100644 --- a/src/plone/rest/errors.py +++ b/src/plone/rest/errors.py @@ -1,5 +1,6 @@ from AccessControl import getSecurityManager + try: from plone.app.redirector.interfaces import IRedirectionStorage except ImportError: @@ -10,10 +11,11 @@ from Products.CMFCore.permissions import ManagePortal from Products.Five.browser import BrowserView from six.moves import urllib -from six.moves.urllib.parse import quote -from six.moves.urllib.parse import unquote +from urllib.parse import quote +from urllib.parse import unquote from zExceptions import NotFound + try: from ZPublisher.HTTPRequest import WSGIRequest @@ -61,7 +63,7 @@ def render_exception(self, exception): if six.PY2: name = name.decode("utf-8") message = message.decode("utf-8") - result = {u"type": name, u"message": message} + result = {"type": name, "message": message} policy = queryMultiAdapter((self.context, self.request), ICORSPolicy) if policy is not None: @@ -77,10 +79,10 @@ def render_exception(self, exception): # NotFound exceptions need special handling because their # exception message gets turned into HTML by ZPublisher url = self.request.getURL() - result[u"message"] = u"Resource not found: %s" % url + result["message"] = "Resource not found: %s" % url if getSecurityManager().checkPermission(ManagePortal, getSite()): - result[u"traceback"] = self.render_traceback(exception) + result["traceback"] = self.render_traceback(exception) return result @@ -101,8 +103,8 @@ def render_traceback(self, exception): pass else: return ( - u"ERROR: Another exception happened before we could " - u"render the traceback." + "ERROR: Another exception happened before we could " + "render the traceback." ) raw = "\n".join(traceback.format_tb(exc_traceback)) @@ -138,17 +140,17 @@ def find_redirect_if_view_or_service(self, old_path_elements, storage): # ['', 'Plone', 'folder', 'item', '@@view', 'param'] # ^ splitpoint = len(old_path_elements) - while splitpoint > 1: possible_obj_path = "/".join(old_path_elements[:splitpoint]) remainder = old_path_elements[splitpoint:] new_path = storage.get(possible_obj_path) if new_path: - if new_path == possible_obj_path: + if new_path.startswith(possible_obj_path): # New URL would match originally requested URL. # Lets not cause a redirect loop. return None + return new_path + "/" + "/".join(remainder) splitpoint -= 1 @@ -162,7 +164,7 @@ def attempt_redirect(self): This method is based on FourOhFourView.attempt_redirect() from p.a.redirector. It's copied here because we want to answer redirects - to non-GET methods with status 308, but since this method locks the + to non-GET methods with status 307, but since this method locks the response status, we wouldn't be able to change it afterwards. """ url = self._url() @@ -178,6 +180,12 @@ def attempt_redirect(self): if storage is None: return False + # remove ++api++ traverser + if "++api++" in old_path_elements: + api_traverser_pos = old_path_elements.index("++api++") + old_path_elements = [el for el in old_path_elements if el != "++api++"] + else: + api_traverser_pos = None old_path = "/".join(old_path_elements) # First lets try with query string in cases or content migration @@ -186,7 +194,7 @@ def attempt_redirect(self): query_string = self.request.QUERY_STRING if query_string: - new_path = storage.get("%s?%s" % (old_path, query_string)) + new_path = storage.get(f"{old_path}?{query_string}") # if we matched on the query_string we don't want to include it # in redirect if new_path: @@ -211,20 +219,25 @@ def attempt_redirect(self): url_path = quote(url_path) url = urllib.parse.SplitResult(*(url[:2] + (url_path,) + url[3:])).geturl() else: + # reinsert ++api++ traverser + if api_traverser_pos is not None: + new_path_elements = new_path.split("/") + new_path_elements.insert(api_traverser_pos, "++api++") + new_path = "/".join(new_path_elements) url = self.request.physicalPathToURL(new_path) # some analytics programs might use this info to track if query_string: url += "?" + query_string - # Answer GET requests with 301. Every other method will be answered - # with 308 Permanent Redirect, which instructs the client to NOT + # Answer GET requests with 302. Every other method will be answered + # with 307 Temporary Redirect, which instructs the client to NOT # switch the method (if the original request was a POST, it should # re-POST to the new URL from the Location header). if self.request.method.upper() == "GET": - status = 301 + status = 302 else: - status = 308 + status = 307 self.request.response.redirect(url, status=status, lock=1) return True diff --git a/src/plone/rest/events.py b/src/plone/rest/events.py index 49554d8b..536ea647 100644 --- a/src/plone/rest/events.py +++ b/src/plone/rest/events.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from plone.rest.cors import lookup_preflight_service_id from plone.rest.interfaces import IAPIRequest from plone.rest.negotiation import lookup_service_id diff --git a/src/plone/rest/explicitacquisition.py b/src/plone/rest/explicitacquisition.py new file mode 100644 index 00000000..8c8b4961 --- /dev/null +++ b/src/plone/rest/explicitacquisition.py @@ -0,0 +1,8 @@ +from plone.rest.interfaces import IShouldAllowAcquiredItemPublication +from plone.rest.traverse import RESTWrapper +from zope.component import adapter + + +@adapter(RESTWrapper) +def rest_allowed(wrapper): + return IShouldAllowAcquiredItemPublication(wrapper.context) diff --git a/src/plone/rest/interfaces.py b/src/plone/rest/interfaces.py index ac182248..a31868d7 100644 --- a/src/plone/rest/interfaces.py +++ b/src/plone/rest/interfaces.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from zope.interface import Interface @@ -20,3 +19,11 @@ def process_simple_request(): def process_preflight_request(): """Process a preflight request""" + + +try: + from Products.CMFCore.interfaces import IShouldAllowAcquiredItemPublication +except ImportError: + + class IShouldAllowAcquiredItemPublication(Interface): + pass diff --git a/src/plone/rest/negotiation.py b/src/plone/rest/negotiation.py index 7a47d5ab..3b23eb41 100644 --- a/src/plone/rest/negotiation.py +++ b/src/plone/rest/negotiation.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # Service registry # A mapping of method -> type name -> subtype name -> service id _services = {} @@ -13,7 +11,7 @@ def parse_accept_header(accept): for media_range in accept.split(","): media_type = media_range.split(";")[0].strip() if "/" in media_type: - type_, subtype = media_type.split("/") + type_, subtype = media_type.split("/", 1) media_types.append((type_, subtype)) return media_types @@ -42,7 +40,7 @@ def register_service(method, media_type): """Register a service for the given request method and media type and return it's service id. """ - service_id = u"{}_{}_{}_".format(method, media_type[0], media_type[1]) + service_id = f"{method}_{media_type[0]}_{media_type[1]}_" types = _services.setdefault(method, {}) subtypes = types.setdefault(media_type[0], {}) subtypes[media_type[1]] = service_id diff --git a/src/plone/rest/patches.py b/src/plone/rest/patches.py index 97b618f1..8f4cb5dc 100644 --- a/src/plone/rest/patches.py +++ b/src/plone/rest/patches.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from plone.rest.interfaces import IAPIRequest diff --git a/src/plone/rest/service.py b/src/plone/rest/service.py index 351b111d..7761f8f7 100644 --- a/src/plone/rest/service.py +++ b/src/plone/rest/service.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from plone.rest.interfaces import ICORSPolicy from plone.rest.interfaces import IService from zope.component import queryMultiAdapter @@ -6,7 +5,7 @@ @implementer(IService) -class Service(object): +class Service: def __call__(self): policy = queryMultiAdapter((self.context, self.request), ICORSPolicy) if policy is not None: @@ -29,4 +28,4 @@ def __getattribute__(self, name): # include credentials if name == "__roles__" and self.request._rest_cors_preflight: return ["Anonymous"] - return super(Service, self).__getattribute__(name) + return super().__getattribute__(name) diff --git a/src/plone/rest/testing.py b/src/plone/rest/testing.py index 05268d94..bcd4eeb3 100644 --- a/src/plone/rest/testing.py +++ b/src/plone/rest/testing.py @@ -1,16 +1,13 @@ -# -*- coding: utf-8 -*- from plone.app.contenttypes.testing import PLONE_APP_CONTENTTYPES_FIXTURE from plone.app.testing import FunctionalTesting from plone.app.testing import IntegrationTesting from plone.app.testing import PloneSandboxLayer from plone.rest.service import Service from plone.testing import z2 - from zope.configuration import xmlconfig class PloneRestLayer(PloneSandboxLayer): - defaultBases = (PLONE_APP_CONTENTTYPES_FIXTURE,) def setUpZope(self, app, configurationContext): @@ -31,7 +28,7 @@ def setUpZope(self, app, configurationContext): class InternalServerErrorService(Service): def __call__(self): - from six.moves.urllib.error import HTTPError + from urllib.error import HTTPError raise HTTPError( "http://nohost/plone/500-internal-server-error", diff --git a/src/plone/rest/tests/__init__.py b/src/plone/rest/tests/__init__.py index 40a96afc..e69de29b 100644 --- a/src/plone/rest/tests/__init__.py +++ b/src/plone/rest/tests/__init__.py @@ -1 +0,0 @@ -# -*- coding: utf-8 -*- diff --git a/src/plone/rest/tests/test_cors.py b/src/plone/rest/tests/test_cors.py index 964ea1d9..ddf1f1a5 100644 --- a/src/plone/rest/tests/test_cors.py +++ b/src/plone/rest/tests/test_cors.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -from ZPublisher.pubevents import PubStart from plone.app.testing import popGlobalRegistry from plone.app.testing import pushGlobalRegistry from plone.rest.cors import CORSPolicy @@ -10,12 +8,12 @@ from zope.event import notify from zope.interface import Interface from zope.publisher.interfaces.browser import IDefaultBrowserLayer +from ZPublisher.pubevents import PubStart import unittest class TestCORSPolicy(unittest.TestCase): - layer = PLONE_REST_INTEGRATION_TESTING def setUp(self): @@ -195,7 +193,6 @@ def test_preflight_cors_sets_status_code_200(self): class TestCORS(unittest.TestCase): - layer = PLONE_REST_INTEGRATION_TESTING def setUp(self): @@ -231,7 +228,7 @@ def test_simple_cors_gets_processed(self): def test_preflight_request_without_cors_policy_doesnt_render_service(self): # "Unregister" the current CORS policy - class NoCORSPolicy(object): + class NoCORSPolicy: def __new__(cls, context, request): return None diff --git a/src/plone/rest/tests/test_dexterity.py b/src/plone/rest/tests/test_dexterity.py index 288ae480..cc411021 100644 --- a/src/plone/rest/tests/test_dexterity.py +++ b/src/plone/rest/tests/test_dexterity.py @@ -1,25 +1,23 @@ -# -*- coding: utf-8 -*- from datetime import datetime from plone.app.testing import setRoles -from plone.app.testing import TEST_USER_ID from plone.app.testing import SITE_OWNER_NAME from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.testing import TEST_USER_ID from plone.app.textfield.value import RichTextValue -from plone.namedfile.file import NamedBlobImage from plone.namedfile.file import NamedBlobFile +from plone.namedfile.file import NamedBlobImage from plone.rest.testing import PLONE_REST_FUNCTIONAL_TESTING from z3c.relationfield import RelationValue from zope.component import getUtility from zope.intid.interfaces import IIntIds -import unittest import os import requests import transaction +import unittest class TestDexterityServiceEndpoints(unittest.TestCase): - layer = PLONE_REST_FUNCTIONAL_TESTING def setUp(self): @@ -39,8 +37,8 @@ def test_dexterity_document_get(self): auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), ) self.assertEqual(200, response.status_code) - self.assertEqual(u"doc1", response.json().get("id")) - self.assertEqual(u"GET", response.json().get("method")) + self.assertEqual("doc1", response.json().get("id")) + self.assertEqual("GET", response.json().get("method")) def test_dexterity_document_post(self): response = requests.post( @@ -49,8 +47,8 @@ def test_dexterity_document_post(self): auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), ) self.assertEqual(200, response.status_code) - self.assertEqual(u"doc1", response.json().get("id")) - self.assertEqual(u"POST", response.json().get("method")) + self.assertEqual("doc1", response.json().get("id")) + self.assertEqual("POST", response.json().get("method")) def test_dexterity_document_put(self): response = requests.put( @@ -59,8 +57,8 @@ def test_dexterity_document_put(self): auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), ) self.assertEqual(200, response.status_code) - self.assertEqual(u"doc1", response.json().get("id")) - self.assertEqual(u"PUT", response.json().get("method")) + self.assertEqual("doc1", response.json().get("id")) + self.assertEqual("PUT", response.json().get("method")) def test_dexterity_document_patch(self): response = requests.patch( @@ -69,8 +67,8 @@ def test_dexterity_document_patch(self): auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), ) self.assertEqual(200, response.status_code) - self.assertEqual(u"doc1", response.json().get("id")) - self.assertEqual(u"PATCH", response.json().get("method")) + self.assertEqual("doc1", response.json().get("id")) + self.assertEqual("PATCH", response.json().get("method")) def test_dexterity_document_delete(self): response = requests.delete( @@ -79,8 +77,8 @@ def test_dexterity_document_delete(self): auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), ) self.assertEqual(200, response.status_code) - self.assertEqual(u"doc1", response.json().get("id")) - self.assertEqual(u"DELETE", response.json().get("method")) + self.assertEqual("doc1", response.json().get("id")) + self.assertEqual("DELETE", response.json().get("method")) def test_dexterity_document_options(self): response = requests.options( @@ -89,8 +87,8 @@ def test_dexterity_document_options(self): auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), ) self.assertEqual(200, response.status_code) - self.assertEqual(u"doc1", response.json().get("id")) - self.assertEqual(u"OPTIONS", response.json().get("method")) + self.assertEqual("doc1", response.json().get("id")) + self.assertEqual("OPTIONS", response.json().get("method")) def test_dexterity_folder_get(self): self.portal.invokeFactory("Folder", id="folder") @@ -103,23 +101,23 @@ def test_dexterity_folder_get(self): ) self.assertEqual(200, response.status_code) - self.assertEqual(u"folder", response.json().get("id")) - self.assertEqual(u"GET", response.json().get("method")) + self.assertEqual("folder", response.json().get("id")) + self.assertEqual("GET", response.json().get("method")) def test_dexterity_news_item_get(self): self.portal.invokeFactory("News Item", id="newsitem") self.portal.newsitem.title = "My News Item" - self.portal.newsitem.description = u"This is a news item" + self.portal.newsitem.description = "This is a news item" self.portal.newsitem.text = RichTextValue( - u"Lorem ipsum", "text/plain", "text/html" + "Lorem ipsum", "text/plain", "text/html" ) - image_file = os.path.join(os.path.dirname(__file__), u"image.png") + image_file = os.path.join(os.path.dirname(__file__), "image.png") fd = open(image_file, "rb") self.portal.newsitem.image = NamedBlobImage( - data=fd.read(), contentType="image/png", filename=u"image.png" + data=fd.read(), contentType="image/png", filename="image.png" ) fd.close() - self.portal.newsitem.image_caption = u"This is an image caption." + self.portal.newsitem.image_caption = "This is an image caption." import transaction transaction.commit() @@ -130,13 +128,13 @@ def test_dexterity_news_item_get(self): ) self.assertEqual(200, response.status_code) - self.assertEqual(u"newsitem", response.json().get("id")) - self.assertEqual(u"GET", response.json().get("method")) + self.assertEqual("newsitem", response.json().get("id")) + self.assertEqual("GET", response.json().get("method")) def test_dexterity_event_get(self): self.portal.invokeFactory("Event", id="event") self.portal.event.title = "Event" - self.portal.event.description = u"This is an event" + self.portal.event.description = "This is an event" self.portal.event.start = datetime(2013, 1, 1, 10, 0) self.portal.event.end = datetime(2013, 1, 1, 12, 0) import transaction @@ -149,13 +147,13 @@ def test_dexterity_event_get(self): ) self.assertEqual(200, response.status_code) - self.assertEqual(u"event", response.json().get("id")) - self.assertEqual(u"GET", response.json().get("method")) + self.assertEqual("event", response.json().get("id")) + self.assertEqual("GET", response.json().get("method")) def test_dexterity_link_get(self): self.portal.invokeFactory("Link", id="link") self.portal.link.title = "My Link" - self.portal.link.description = u"This is a link" + self.portal.link.description = "This is a link" self.portal.remoteUrl = "http://plone.org" import transaction @@ -167,17 +165,17 @@ def test_dexterity_link_get(self): ) self.assertEqual(200, response.status_code) - self.assertEqual(u"link", response.json().get("id")) - self.assertEqual(u"GET", response.json().get("method")) + self.assertEqual("link", response.json().get("id")) + self.assertEqual("GET", response.json().get("method")) def test_dexterity_file_get(self): self.portal.invokeFactory("File", id="file") self.portal.file.title = "My File" - self.portal.file.description = u"This is a file" - pdf_file = os.path.join(os.path.dirname(__file__), u"file.pdf") + self.portal.file.description = "This is a file" + pdf_file = os.path.join(os.path.dirname(__file__), "file.pdf") fd = open(pdf_file, "rb") self.portal.file.file = NamedBlobFile( - data=fd.read(), contentType="application/pdf", filename=u"file.pdf" + data=fd.read(), contentType="application/pdf", filename="file.pdf" ) fd.close() intids = getUtility(IIntIds) @@ -194,17 +192,17 @@ def test_dexterity_file_get(self): ) self.assertEqual(200, response.status_code) - self.assertEqual(u"file", response.json().get("id")) - self.assertEqual(u"GET", response.json().get("method")) + self.assertEqual("file", response.json().get("id")) + self.assertEqual("GET", response.json().get("method")) def test_dexterity_image_get(self): self.portal.invokeFactory("Image", id="image") self.portal.image.title = "My Image" - self.portal.image.description = u"This is an image" - image_file = os.path.join(os.path.dirname(__file__), u"image.png") + self.portal.image.description = "This is an image" + image_file = os.path.join(os.path.dirname(__file__), "image.png") fd = open(image_file, "rb") self.portal.image.image = NamedBlobImage( - data=fd.read(), contentType="image/png", filename=u"image.png" + data=fd.read(), contentType="image/png", filename="image.png" ) fd.close() import transaction @@ -218,13 +216,13 @@ def test_dexterity_image_get(self): ) self.assertEqual(200, response.status_code) - self.assertEqual(u"image", response.json().get("id")) - self.assertEqual(u"GET", response.json().get("method")) + self.assertEqual("image", response.json().get("id")) + self.assertEqual("GET", response.json().get("method")) def test_dexterity_collection_get(self): self.portal.invokeFactory("Collection", id="collection") self.portal.collection.title = "My Collection" - self.portal.collection.description = u"This is a collection with two documents" + self.portal.collection.description = "This is a collection with two documents" self.portal.collection.query = [ { "i": "portal_type", @@ -243,5 +241,5 @@ def test_dexterity_collection_get(self): ) self.assertEqual(200, response.status_code) - self.assertEqual(u"collection", response.json().get("id")) - self.assertEqual(u"GET", response.json().get("method")) + self.assertEqual("collection", response.json().get("id")) + self.assertEqual("GET", response.json().get("method")) diff --git a/src/plone/rest/tests/test_dispatching.py b/src/plone/rest/tests/test_dispatching.py index 674e2f67..5c9204d0 100644 --- a/src/plone/rest/tests/test_dispatching.py +++ b/src/plone/rest/tests/test_dispatching.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from plone.app.testing import setRoles from plone.app.testing import SITE_OWNER_NAME from plone.app.testing import SITE_OWNER_PASSWORD @@ -17,7 +16,6 @@ class DispatchingTestCase(unittest.TestCase): - layer = PLONE_REST_FUNCTIONAL_TESTING def setUp(self): @@ -54,7 +52,7 @@ def validate(self, expectations, follow_redirects=False): if failures: msg = "" - for (request_args, expected_status, actual_status) in failures: + for request_args, expected_status, actual_status in failures: msg += ( "\n" "Request: %s\n" @@ -137,7 +135,7 @@ def test_not_found_invalid_creds(self): class TestDispatchingDexterity(DispatchingTestCase): def setUp(self): - super(TestDispatchingDexterity, self).setUp() + super().setUp() self.portal.invokeFactory("Folder", id="private") self.portal.invokeFactory("Folder", id="public") @@ -216,7 +214,7 @@ def test_public_dx_folder_invalid_creds(self): class TestDispatchingRedirects(DispatchingTestCase): def setUp(self): - super(TestDispatchingRedirects, self).setUp() + super().setUp() self.portal.invokeFactory("Folder", id="private-old") self.portal.manage_renameObject("private-old", "private-new") @@ -231,12 +229,12 @@ def setUp(self): def test_moved_private_dx_folder_with_creds(self): expectations = [ - ("/private-old", "GET", CREDS, 301), - ("/private-old", "POST", CREDS, 308), - ("/private-old", "PUT", CREDS, 308), - ("/private-old", "PATCH", CREDS, 308), - ("/private-old", "DELETE", CREDS, 308), - ("/private-old", "OPTIONS", CREDS, 308), + ("/private-old", "GET", CREDS, 302), + ("/private-old", "POST", CREDS, 307), + ("/private-old", "PUT", CREDS, 307), + ("/private-old", "PATCH", CREDS, 307), + ("/private-old", "DELETE", CREDS, 307), + ("/private-old", "OPTIONS", CREDS, 307), ] self.validate(expectations) @@ -253,12 +251,12 @@ def test_moved_private_dx_folder_with_creds(self): def test_moved_private_dx_folder_without_creds(self): expectations = [ - ("/private-old", "GET", NO_CREDS, 301), - ("/private-old", "POST", NO_CREDS, 308), - ("/private-old", "PUT", NO_CREDS, 308), - ("/private-old", "PATCH", NO_CREDS, 308), - ("/private-old", "DELETE", NO_CREDS, 308), - ("/private-old", "OPTIONS", NO_CREDS, 308), + ("/private-old", "GET", NO_CREDS, 302), + ("/private-old", "POST", NO_CREDS, 307), + ("/private-old", "PUT", NO_CREDS, 307), + ("/private-old", "PATCH", NO_CREDS, 307), + ("/private-old", "DELETE", NO_CREDS, 307), + ("/private-old", "OPTIONS", NO_CREDS, 307), ] self.validate(expectations) @@ -275,12 +273,12 @@ def test_moved_private_dx_folder_without_creds(self): def test_moved_private_dx_folder_invalid_creds(self): expectations = [ - ("/private-old", "GET", INVALID_CREDS, 301), - ("/private-old", "POST", INVALID_CREDS, 308), - ("/private-old", "PUT", INVALID_CREDS, 308), - ("/private-old", "PATCH", INVALID_CREDS, 308), - ("/private-old", "DELETE", INVALID_CREDS, 308), - ("/private-old", "OPTIONS", INVALID_CREDS, 308), + ("/private-old", "GET", INVALID_CREDS, 302), + ("/private-old", "POST", INVALID_CREDS, 307), + ("/private-old", "PUT", INVALID_CREDS, 307), + ("/private-old", "PATCH", INVALID_CREDS, 307), + ("/private-old", "DELETE", INVALID_CREDS, 307), + ("/private-old", "OPTIONS", INVALID_CREDS, 307), ] self.validate(expectations) @@ -297,12 +295,12 @@ def test_moved_private_dx_folder_invalid_creds(self): def test_moved_public_dx_folder_with_creds(self): expectations = [ - ("/public-old", "GET", CREDS, 301), - ("/public-old", "POST", CREDS, 308), - ("/public-old", "PUT", CREDS, 308), - ("/public-old", "PATCH", CREDS, 308), - ("/public-old", "DELETE", CREDS, 308), - ("/public-old", "OPTIONS", CREDS, 308), + ("/public-old", "GET", CREDS, 302), + ("/public-old", "POST", CREDS, 307), + ("/public-old", "PUT", CREDS, 307), + ("/public-old", "PATCH", CREDS, 307), + ("/public-old", "DELETE", CREDS, 307), + ("/public-old", "OPTIONS", CREDS, 307), ] self.validate(expectations) @@ -319,12 +317,12 @@ def test_moved_public_dx_folder_with_creds(self): def test_moved_public_dx_folder_without_creds(self): expectations = [ - ("/public-old", "GET", NO_CREDS, 301), - ("/public-old", "POST", NO_CREDS, 308), - ("/public-old", "PUT", NO_CREDS, 308), - ("/public-old", "PATCH", NO_CREDS, 308), - ("/public-old", "DELETE", NO_CREDS, 308), - ("/public-old", "OPTIONS", NO_CREDS, 308), + ("/public-old", "GET", NO_CREDS, 302), + ("/public-old", "POST", NO_CREDS, 307), + ("/public-old", "PUT", NO_CREDS, 307), + ("/public-old", "PATCH", NO_CREDS, 307), + ("/public-old", "DELETE", NO_CREDS, 307), + ("/public-old", "OPTIONS", NO_CREDS, 307), ] self.validate(expectations) @@ -341,12 +339,12 @@ def test_moved_public_dx_folder_without_creds(self): def test_moved_public_dx_folder_invalid_creds(self): expectations = [ - ("/public-old", "GET", INVALID_CREDS, 301), - ("/public-old", "POST", INVALID_CREDS, 308), - ("/public-old", "PUT", INVALID_CREDS, 308), - ("/public-old", "PATCH", INVALID_CREDS, 308), - ("/public-old", "DELETE", INVALID_CREDS, 308), - ("/public-old", "OPTIONS", INVALID_CREDS, 308), + ("/public-old", "GET", INVALID_CREDS, 302), + ("/public-old", "POST", INVALID_CREDS, 307), + ("/public-old", "PUT", INVALID_CREDS, 307), + ("/public-old", "PATCH", INVALID_CREDS, 307), + ("/public-old", "DELETE", INVALID_CREDS, 307), + ("/public-old", "OPTIONS", INVALID_CREDS, 307), ] self.validate(expectations) diff --git a/src/plone/rest/tests/test_error_handling.py b/src/plone/rest/tests/test_error_handling.py index b2cf5111..32711a4b 100644 --- a/src/plone/rest/tests/test_error_handling.py +++ b/src/plone/rest/tests/test_error_handling.py @@ -1,9 +1,8 @@ -# -*- coding: utf-8 -*- from plone.app.testing import setRoles -from plone.app.testing import TEST_USER_ID -from plone.app.testing import TEST_USER_PASSWORD from plone.app.testing import SITE_OWNER_NAME from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.testing import TEST_USER_ID +from plone.app.testing import TEST_USER_PASSWORD from plone.rest.testing import PLONE_REST_FUNCTIONAL_TESTING import json @@ -13,7 +12,6 @@ class TestErrorHandling(unittest.TestCase): - layer = PLONE_REST_FUNCTIONAL_TESTING def setUp(self): @@ -83,7 +81,7 @@ def test_500_internal_server_error(self): self.assertEqual("HTTPError", response.json()["type"]) self.assertEqual( - {u"type": u"HTTPError", u"message": u"HTTP Error 500: InternalServerError"}, + {"type": "HTTPError", "message": "HTTP Error 500: InternalServerError"}, response.json(), ) @@ -94,7 +92,7 @@ def test_500_traceback_only_for_manager_users(self): headers={"Accept": "application/json"}, auth=(TEST_USER_ID, TEST_USER_PASSWORD), ) - self.assertNotIn(u"traceback", response.json()) + self.assertNotIn("traceback", response.json()) # Manager user response = requests.get( @@ -102,10 +100,10 @@ def test_500_traceback_only_for_manager_users(self): headers={"Accept": "application/json"}, auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), ) - self.assertIn(u"traceback", response.json()) + self.assertIn("traceback", response.json()) - traceback = response.json()[u"traceback"] + traceback = response.json()["traceback"] self.assertIsInstance(traceback, list) - self.assertRegexpMatches( + self.assertRegex( traceback[0], r'^File "[^"]*", line \d*, in (publish|transaction_pubevents)' ) diff --git a/src/plone/rest/tests/test_explicitacquisition.py b/src/plone/rest/tests/test_explicitacquisition.py new file mode 100644 index 00000000..7c5d8cc2 --- /dev/null +++ b/src/plone/rest/tests/test_explicitacquisition.py @@ -0,0 +1,126 @@ +from base64 import b64encode +from plone.app.testing import setRoles +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.testing import TEST_USER_ID +from plone.rest.testing import PLONE_REST_INTEGRATION_TESTING +from zExceptions import NotFound +from zope.event import notify +from ZPublisher.pubevents import PubAfterTraversal +from ZPublisher.pubevents import PubStart + +import unittest + + +try: + from Products.CMFCore.interfaces import IShouldAllowAcquiredItemPublication + + IShouldAllowAcquiredItemPublication # flake8 + + HAS_CMFCORE_32 = True +except ImportError: + HAS_CMFCORE_32 = False + + +@unittest.skipUnless( + not HAS_CMFCORE_32, + "Older Plone versions don't have CMFCore>=3.2", +) +class TestExplicitAcquisitionUnavailable(unittest.TestCase): + layer = PLONE_REST_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + self.request = self.layer["request"] + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + self.portal.invokeFactory("Document", id="foo") + + def traverse(self, path="/plone", accept="application/json", method="GET"): + request = self.layer["request"] + request.environ["PATH_INFO"] = path + request.environ["PATH_TRANSLATED"] = path + request.environ["HTTP_ACCEPT"] = accept + request.environ["REQUEST_METHOD"] = method + auth = f"{SITE_OWNER_NAME}:{SITE_OWNER_PASSWORD}" + b64auth = b64encode(auth.encode("utf8")) + request._auth = "Basic %s" % b64auth.decode("utf8") + notify(PubStart(request)) + return request.traverse(path) + + def test_portal_root(self): + self.traverse("/plone") + notify(PubAfterTraversal(self.request)) + + def test_portal_foo(self): + self.traverse("/plone/foo") + notify(PubAfterTraversal(self.request)) + + def test_portal_foo_acquired(self): + self.traverse("/plone/foo/foo") + notify(PubAfterTraversal(self.request)) + + +@unittest.skipUnless( + HAS_CMFCORE_32, + "We have Products.CMFCore >= 3.2", +) +class TestExplicitAcquisitionAvailable(unittest.TestCase): + layer = PLONE_REST_INTEGRATION_TESTING + + def setUp(self): + import Products.CMFCore.explicitacquisition + + self.portal = self.layer["portal"] + self.request = self.layer["request"] + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + self.portal.invokeFactory("Document", id="foo") + self.PREVIOUS_SKIP_PTA = Products.CMFCore.explicitacquisition.SKIP_PTA + + def tearDown(self): + import Products.CMFCore.explicitacquisition + + Products.CMFCore.explicitacquisition.SKIP_PTA = self.PREVIOUS_SKIP_PTA + + def traverse(self, path="/plone", accept="application/json", method="GET"): + request = self.layer["request"] + request.environ["PATH_INFO"] = path + request.environ["PATH_TRANSLATED"] = path + request.environ["HTTP_ACCEPT"] = accept + request.environ["REQUEST_METHOD"] = method + auth = f"{SITE_OWNER_NAME}:{SITE_OWNER_PASSWORD}" + b64auth = b64encode(auth.encode("utf8")) + request._auth = "Basic %s" % b64auth.decode("utf8") + notify(PubStart(request)) + return request.traverse(path) + + def test_portal_root(self): + import Products.CMFCore.explicitacquisition + + self.traverse("/plone") + Products.CMFCore.explicitacquisition.SKIP_PTA = True + notify(PubAfterTraversal(self.request)) + + Products.CMFCore.explicitacquisition.SKIP_PTA = False + notify(PubAfterTraversal(self.request)) + + def test_portal_foo(self): + import Products.CMFCore.explicitacquisition + + self.traverse("/plone/foo") + Products.CMFCore.explicitacquisition.SKIP_PTA = True + notify(PubAfterTraversal(self.request)) + + Products.CMFCore.explicitacquisition.SKIP_PTA = False + notify(PubAfterTraversal(self.request)) + + def test_portal_foo_acquired(self): + import Products.CMFCore.explicitacquisition + + self.traverse("/plone/foo/foo") + + Products.CMFCore.explicitacquisition.SKIP_PTA = True + notify(PubAfterTraversal(self.request)) + + Products.CMFCore.explicitacquisition.SKIP_PTA = False + with self.assertRaises(NotFound): + notify(PubAfterTraversal(self.request)) diff --git a/src/plone/rest/tests/test_named_services.py b/src/plone/rest/tests/test_named_services.py index fe1977bb..05feb60d 100644 --- a/src/plone/rest/tests/test_named_services.py +++ b/src/plone/rest/tests/test_named_services.py @@ -1,17 +1,15 @@ -# -*- coding: utf-8 -*- from plone.app.testing import setRoles -from plone.app.testing import TEST_USER_ID from plone.app.testing import SITE_OWNER_NAME from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.testing import TEST_USER_ID from plone.rest.testing import PLONE_REST_FUNCTIONAL_TESTING -import unittest import requests import transaction +import unittest class TestNamedServiceEndpoints(unittest.TestCase): - layer = PLONE_REST_FUNCTIONAL_TESTING def setUp(self): @@ -31,7 +29,7 @@ def test_dexterity_named_get(self): auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), ) self.assertEqual(response.status_code, 200) - self.assertEqual({u"service": u"named get"}, response.json()) + self.assertEqual({"service": "named get"}, response.json()) def test_dexterity_named_post(self): response = requests.post( @@ -40,7 +38,7 @@ def test_dexterity_named_post(self): auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), ) self.assertEqual(response.status_code, 200) - self.assertEqual({u"service": u"named post"}, response.json()) + self.assertEqual({"service": "named post"}, response.json()) def test_dexterity_named_put(self): response = requests.put( @@ -49,7 +47,7 @@ def test_dexterity_named_put(self): auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), ) self.assertEqual(response.status_code, 200) - self.assertEqual({u"service": u"named put"}, response.json()) + self.assertEqual({"service": "named put"}, response.json()) def test_dexterity_named_patch(self): response = requests.patch( @@ -58,7 +56,7 @@ def test_dexterity_named_patch(self): auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), ) self.assertEqual(response.status_code, 200) - self.assertEqual({u"service": u"named patch"}, response.json()) + self.assertEqual({"service": "named patch"}, response.json()) def test_dexterity_named_delete(self): response = requests.delete( @@ -67,7 +65,7 @@ def test_dexterity_named_delete(self): auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), ) self.assertEqual(response.status_code, 200) - self.assertEqual({u"service": u"named delete"}, response.json()) + self.assertEqual({"service": "named delete"}, response.json()) def test_dexterity_named_options(self): response = requests.options( @@ -76,4 +74,4 @@ def test_dexterity_named_options(self): auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), ) self.assertEqual(response.status_code, 200) - self.assertEqual({u"service": u"named options"}, response.json()) + self.assertEqual({"service": "named options"}, response.json()) diff --git a/src/plone/rest/tests/test_negotiation.py b/src/plone/rest/tests/test_negotiation.py index 70edd5ae..22502db6 100644 --- a/src/plone/rest/tests/test_negotiation.py +++ b/src/plone/rest/tests/test_negotiation.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from plone.rest.negotiation import lookup_service_id from plone.rest.negotiation import parse_accept_header from plone.rest.negotiation import register_service @@ -57,28 +56,33 @@ def test_parse_all_media_types_accept_header(self): def test_parse_invalid_accept_header(self): self.assertEqual([], parse_accept_header("invalid")) + def test_parse_mimetype_with_extra_slash(self): + self.assertEqual( + [("application", "x/y")], parse_accept_header("application/x/y") + ) + class TestServiceRegistry(unittest.TestCase): def test_register_media_type(self): self.assertEqual( - u"GET_application_json_", register_service("GET", ("application", "json")) + "GET_application_json_", register_service("GET", ("application", "json")) ) self.assertEqual( - u"GET_application_json_", lookup_service_id("GET", "application/json") + "GET_application_json_", lookup_service_id("GET", "application/json") ) def test_register_wildcard_subtype(self): - self.assertEqual(u"PATCH_text_*_", register_service("PATCH", ("text", "*"))) - self.assertEqual(u"PATCH_text_*_", lookup_service_id("PATCH", "text/xml")) + self.assertEqual("PATCH_text_*_", register_service("PATCH", ("text", "*"))) + self.assertEqual("PATCH_text_*_", lookup_service_id("PATCH", "text/xml")) def test_register_wilcard_type(self): - self.assertEqual(u"PATCH_*_*_", register_service("PATCH", ("*", "*"))) - self.assertEqual(u"PATCH_*_*_", lookup_service_id("PATCH", "foo/bar")) + self.assertEqual("PATCH_*_*_", register_service("PATCH", ("*", "*"))) + self.assertEqual("PATCH_*_*_", lookup_service_id("PATCH", "foo/bar")) def test_service_id_for_multiple_media_types_is_none(self): register_service("GET", "application/json") self.assertEqual( - None, lookup_service_id("GET", "application/json,application/javascipt") + None, lookup_service_id("GET", "application/json,application/javascript") ) def test_service_id_for_invalid_media_type_is_none(self): diff --git a/src/plone/rest/tests/test_permissions.py b/src/plone/rest/tests/test_permissions.py index 2ab0df6a..166b0974 100644 --- a/src/plone/rest/tests/test_permissions.py +++ b/src/plone/rest/tests/test_permissions.py @@ -1,23 +1,21 @@ -# -*- coding: utf-8 -*- -from Products.CMFCore.utils import getToolByName -from ZPublisher.pubevents import PubStart from base64 import b64encode +from plone.app.testing import login +from plone.app.testing import setRoles from plone.app.testing import SITE_OWNER_NAME from plone.app.testing import TEST_USER_ID from plone.app.testing import TEST_USER_NAME from plone.app.testing import TEST_USER_PASSWORD -from plone.app.testing import login -from plone.app.testing import setRoles from plone.rest.service import Service from plone.rest.testing import PLONE_REST_INTEGRATION_TESTING +from Products.CMFCore.utils import getToolByName from zExceptions import Unauthorized from zope.event import notify +from ZPublisher.pubevents import PubStart import unittest class TestPermissions(unittest.TestCase): - layer = PLONE_REST_INTEGRATION_TESTING def setUp(self): @@ -36,7 +34,7 @@ def traverse(self, path="/plone", accept="application/json", method="GET"): request.environ["PATH_TRANSLATED"] = path request.environ["HTTP_ACCEPT"] = accept request.environ["REQUEST_METHOD"] = method - auth = "%s:%s" % (TEST_USER_NAME, TEST_USER_PASSWORD) + auth = f"{TEST_USER_NAME}:{TEST_USER_PASSWORD}" b64auth = b64encode(auth.encode("utf8")) request._auth = "Basic %s" % b64auth.decode("utf8") notify(PubStart(request)) diff --git a/src/plone/rest/tests/test_redirects.py b/src/plone/rest/tests/test_redirects.py index 37c61dca..a172a4d9 100644 --- a/src/plone/rest/tests/test_redirects.py +++ b/src/plone/rest/tests/test_redirects.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from BTrees.OOBTree import OOSet from plone.app.redirector.interfaces import IRedirectionStorage from plone.app.testing import setRoles @@ -15,7 +14,6 @@ class TestRedirects(unittest.TestCase): - layer = PLONE_REST_FUNCTIONAL_TESTING def setUp(self): @@ -28,28 +26,73 @@ def setUp(self): self.portal.manage_renameObject("folder-old", "folder-new") transaction.commit() - def test_get_to_moved_item_causes_301_redirect(self): + def test_get_to_moved_item_causes_302_redirect(self): response = requests.get( self.portal_url + "/folder-old", headers={"Accept": "application/json"}, auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), allow_redirects=False, ) - self.assertEqual(301, response.status_code) + self.assertEqual(302, response.status_code) self.assertEqual(self.portal_url + "/folder-new", response.headers["Location"]) self.assertEqual(b"", response.raw.read()) - def test_post_to_moved_item_causes_308_redirect(self): + def test_get_to_moved_item_causes_302_redirect_with_api_traverser(self): + response = requests.get( + self.portal_url + "/++api++/folder-old", + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + allow_redirects=False, + ) + self.assertEqual(302, response.status_code) + self.assertEqual( + self.portal_url + "/++api++/folder-new", response.headers["Location"] + ) + self.assertEqual(b"", response.raw.read()) + # follow the new location + response = requests.get( + response.headers["Location"], + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + ) + self.assertEqual(200, response.status_code) + self.assertEqual("application/json", response.headers["Content-type"]) + self.assertEqual({"id": "folder-new", "method": "GET"}, response.json()) + + def test_get_to_moved_item_causes_302_redirect_with_rest_view(self): + response = requests.get( + self.portal_url + "/++api++/folder-old/@actions", + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + allow_redirects=False, + ) + self.assertEqual(302, response.status_code) + self.assertEqual( + self.portal_url + "/++api++/folder-new/@actions", + response.headers["Location"], + ) + self.assertEqual(b"", response.raw.read()) + + def test_post_to_moved_item_causes_307_redirect(self): response = requests.post( self.portal_url + "/folder-old", headers={"Accept": "application/json"}, auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), allow_redirects=False, ) - self.assertEqual(308, response.status_code) + self.assertEqual(307, response.status_code) self.assertEqual(self.portal_url + "/folder-new", response.headers["Location"]) self.assertEqual(b"", response.raw.read()) + def test_post_to_moved_item_causes_307_redirect_with_api_traverser(self): + response = requests.post( + self.portal_url + "/++api++/folder-old", + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + allow_redirects=False, + ) + self.assertEqual(307, response.status_code) + self.assertEqual( + self.portal_url + "/++api++/folder-new", response.headers["Location"] + ) + self.assertEqual(b"", response.raw.read()) + def test_unauthorized_request_to_item_still_redirects_first(self): response = requests.get( self.portal_url + "/folder-old", @@ -60,7 +103,7 @@ def test_unauthorized_request_to_item_still_redirects_first(self): # A request to the old URL of an item where the user doesn't have # necessary permissions will still result in a redirect - self.assertEqual(301, response.status_code) + self.assertEqual(302, response.status_code) self.assertEqual(self.portal_url + "/folder-new", response.headers["Location"]) self.assertEqual(b"", response.raw.read()) @@ -80,20 +123,20 @@ def test_query_string_gets_preserved(self): auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), allow_redirects=False, ) - self.assertEqual(301, response.status_code) + self.assertEqual(302, response.status_code) self.assertEqual( self.portal_url + "/folder-new?key=value", response.headers["Location"] ) self.assertEqual(b"", response.raw.read()) - def test_named_service_on_moved_item_causes_301_redirect(self): + def test_named_service_on_moved_item_causes_302_redirect(self): response = requests.get( self.portal_url + "/folder-old/namedservice", headers={"Accept": "application/json"}, auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), allow_redirects=False, ) - self.assertEqual(301, response.status_code) + self.assertEqual(302, response.status_code) self.assertEqual( self.portal_url + "/folder-new/namedservice", response.headers["Location"] ) @@ -106,7 +149,7 @@ def test_named_service_plus_path_parameter_works(self): auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), allow_redirects=False, ) - self.assertEqual(301, response.status_code) + self.assertEqual(302, response.status_code) self.assertEqual( self.portal_url + "/folder-new/namedservice/param", response.headers["Location"], @@ -120,7 +163,7 @@ def test_redirects_for_regular_views_still_work(self): auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), allow_redirects=False, ) - self.assertEqual(301, response.status_code) + self.assertEqual(302, response.status_code) self.assertEqual( self.portal_url + "/folder-new/@@some-view", response.headers["Location"] ) @@ -133,7 +176,7 @@ def test_redirects_for_views_plus_params_plus_querystring_works(self): auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), allow_redirects=False, ) - self.assertEqual(301, response.status_code) + self.assertEqual(302, response.status_code) self.assertEqual( self.portal_url + "/folder-new/@@some-view/param?k=v", response.headers["Location"], @@ -165,10 +208,22 @@ def test_handles_redirects_that_include_querystring_in_old_path(self): auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), allow_redirects=False, ) - self.assertEqual(301, response.status_code) + self.assertEqual(302, response.status_code) self.assertEqual(self.portal_url + "/new-item", response.headers["Location"]) self.assertEqual(b"", response.raw.read()) + def test_handles_redirects_that_are_recursive(self): + storage = queryUtility(IRedirectionStorage) + storage.add("/plone/folder-new", "/plone/folder-new/archive") + transaction.commit() + # Request should return 404 + response = requests.get( + self.portal_url + "/folder-new/sub_folder/not-found", + headers={"Accept": "application/json"}, + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + ) + self.assertEqual(404, response.status_code) + def test_aborts_redirect_checks_early_for_app_root(self): error_view = ErrorHandling(self.portal, self.portal.REQUEST) self.assertIsNone(error_view.find_redirect_if_view_or_service([""], None)) @@ -176,4 +231,4 @@ def test_aborts_redirect_checks_early_for_app_root(self): def test_gracefully_deals_with_missing_request_url(self): error_view = ErrorHandling(self.portal, self.portal.REQUEST) self.portal.REQUEST["ACTUAL_URL"] = None - self.assertEquals(False, error_view.attempt_redirect()) + self.assertEqual(False, error_view.attempt_redirect()) diff --git a/src/plone/rest/tests/test_siteroot.py b/src/plone/rest/tests/test_siteroot.py index 6cf71d59..9e992938 100644 --- a/src/plone/rest/tests/test_siteroot.py +++ b/src/plone/rest/tests/test_siteroot.py @@ -1,16 +1,14 @@ -# -*- coding: utf-8 -*- -from plone.rest.testing import PLONE_REST_FUNCTIONAL_TESTING from plone.app.testing import setRoles -from plone.app.testing import TEST_USER_ID from plone.app.testing import SITE_OWNER_NAME from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.testing import TEST_USER_ID +from plone.rest.testing import PLONE_REST_FUNCTIONAL_TESTING -import unittest import requests +import unittest class TestSiteRootServiceEndpoints(unittest.TestCase): - layer = PLONE_REST_FUNCTIONAL_TESTING def setUp(self): @@ -33,8 +31,8 @@ def test_siteroot_get(self): response.status_code ), ) - self.assertEqual(u"plone", response.json().get("id")) - self.assertEqual(u"GET", response.json().get("method")) + self.assertEqual("plone", response.json().get("id")) + self.assertEqual("GET", response.json().get("method")) def test_siteroot_post(self): response = requests.post( @@ -49,8 +47,8 @@ def test_siteroot_post(self): response.status_code ), ) - self.assertEqual(u"plone", response.json().get("id")) - self.assertEqual(u"POST", response.json().get("method")) + self.assertEqual("plone", response.json().get("id")) + self.assertEqual("POST", response.json().get("method")) def test_siteroot_delete(self): response = requests.delete( @@ -59,8 +57,8 @@ def test_siteroot_delete(self): auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), ) self.assertEqual(response.status_code, 200) - self.assertEqual(u"plone", response.json().get("id")) - self.assertEqual(u"DELETE", response.json().get("method")) + self.assertEqual("plone", response.json().get("id")) + self.assertEqual("DELETE", response.json().get("method")) def test_siteroot_put(self): response = requests.put( @@ -70,8 +68,8 @@ def test_siteroot_put(self): ) self.assertEqual(response.status_code, 200) - self.assertEqual(u"plone", response.json().get("id")) - self.assertEqual(u"PUT", response.json().get("method")) + self.assertEqual("plone", response.json().get("id")) + self.assertEqual("PUT", response.json().get("method")) def test_siteroot_patch(self): response = requests.patch( @@ -81,8 +79,8 @@ def test_siteroot_patch(self): ) self.assertEqual(response.status_code, 200) - self.assertEqual(u"plone", response.json().get("id")) - self.assertEqual(u"PATCH", response.json().get("method")) + self.assertEqual("plone", response.json().get("id")) + self.assertEqual("PATCH", response.json().get("method")) def test_siteroot_options(self): response = requests.options( @@ -91,5 +89,5 @@ def test_siteroot_options(self): auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), ) self.assertEqual(response.status_code, 200) - self.assertEqual(u"plone", response.json().get("id")) - self.assertEqual(u"OPTIONS", response.json().get("method")) + self.assertEqual("plone", response.json().get("id")) + self.assertEqual("OPTIONS", response.json().get("method")) diff --git a/src/plone/rest/tests/test_traversal.py b/src/plone/rest/tests/test_traversal.py index 8701c2cb..7127dab2 100644 --- a/src/plone/rest/tests/test_traversal.py +++ b/src/plone/rest/tests/test_traversal.py @@ -1,7 +1,3 @@ -# -*- coding: utf-8 -*- -from Products.SiteAccess.VirtualHostMonster import VirtualHostMonster -from ZPublisher import BeforeTraverse -from ZPublisher.pubevents import PubStart from base64 import b64encode from plone.app.layout.navigation.interfaces import INavigationRoot from plone.app.testing import setRoles @@ -10,15 +6,19 @@ from plone.app.testing import TEST_USER_ID from plone.rest.service import Service from plone.rest.testing import PLONE_REST_INTEGRATION_TESTING +from Products.SiteAccess.VirtualHostMonster import VirtualHostMonster +from zExceptions import NotFound +from zExceptions import Redirect from zope.event import notify from zope.interface import alsoProvides from zope.publisher.interfaces.browser import IBrowserView +from ZPublisher import BeforeTraverse +from ZPublisher.pubevents import PubStart import unittest class TestTraversal(unittest.TestCase): - layer = PLONE_REST_INTEGRATION_TESTING def setUp(self): @@ -32,7 +32,7 @@ def traverse(self, path="/plone", accept="application/json", method="GET"): request.environ["PATH_TRANSLATED"] = path request.environ["HTTP_ACCEPT"] = accept request.environ["REQUEST_METHOD"] = method - auth = "%s:%s" % (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + auth = f"{SITE_OWNER_NAME}:{SITE_OWNER_PASSWORD}" b64auth = b64encode(auth.encode("utf8")) request._auth = "Basic %s" % b64auth.decode("utf8") notify(PubStart(request)) @@ -60,18 +60,18 @@ def test_json_request_on_content_object_returns_service(self): def test_html_request_on_portal_root_returns_default_view(self): obj = self.traverse(accept="text/html") - self.assertEquals("listing_view", obj.__name__) + self.assertEqual(self.portal.getDefaultLayout(), obj.__name__) def test_html_request_on_portal_root_returns_dynamic_view(self): self.portal.setLayout("summary_view") obj = self.traverse(accept="text/html") - self.assertEquals("summary_view", obj.__name__) + self.assertEqual("summary_view", obj.__name__) def test_html_request_on_portal_root_returns_default_page(self): self.portal.invokeFactory("Document", id="doc1") self.portal.setDefaultPage("doc1") obj = self.traverse(accept="text/html") - self.assertEquals("document_view", obj.__name__) + self.assertEqual("document_view", obj.__name__) def test_json_request_on_object_with_multihook(self): doc1 = self.portal[self.portal.invokeFactory("Document", id="doc1")] @@ -86,7 +86,7 @@ def btr_test(container, request): obj = self.traverse(path="/plone/doc1") self.assertTrue(isinstance(obj, Service), "Not a service") - self.assertEquals(1, self.request._btr_test_called) + self.assertEqual(1, self.request._btr_test_called) def test_json_request_on_existing_view_returns_named_service(self): obj = self.traverse("/plone/search") @@ -106,6 +106,34 @@ def test_html_request_on_existing_view_returns_view(self): obj = self.traverse(path="/plone/folder1/search", accept="text/html") self.assertFalse(isinstance(obj, Service), "Got a service") + def test_html_request_via_api_returns_service(self): + obj = self.traverse(path="/plone/++api++", accept="text/html") + self.assertTrue(isinstance(obj, Service), "Not a service") + + def test_html_request_via_double_apis_raises_redirect(self): + portal_url = self.portal.absolute_url() + with self.assertRaises(Redirect) as exc: + self.traverse(path="/plone/++api++/++api++", accept="text/html") + self.assertEqual( + exc.exception.headers["Location"], + f"{portal_url}/++api++", + ) + + def test_html_request_via_multiple_apis_raises_redirect(self): + portal_url = self.portal.absolute_url() + with self.assertRaises(Redirect) as exc: + self.traverse( + path="/plone/++api++/++api++/++api++/search", accept="text/html" + ) + self.assertEqual( + exc.exception.headers["Location"], + f"{portal_url}/++api++/search", + ) + + def test_html_request_via_multiple_bad_apis_raises_not_found(self): + with self.assertRaises(NotFound): + self.traverse(path="/plone/++api++/search/++api++", accept="text/html") + def test_json_request_to_regular_view_returns_view(self): obj = self.traverse("/plone/folder_contents") self.assertTrue(IBrowserView.providedBy(obj), "IBrowserView expected") diff --git a/src/plone/rest/traverse.py b/src/plone/rest/traverse.py index 99573895..d29464b9 100644 --- a/src/plone/rest/traverse.py +++ b/src/plone/rest/traverse.py @@ -1,14 +1,15 @@ # -*- coding: utf-8 -*- -from plone.rest.interfaces import IAPIRequest from plone.rest.events import mark_as_api_request +from plone.rest.interfaces import IAPIRequest +from Products.CMFCore.interfaces import IContentish +from Products.CMFCore.interfaces import ISiteRoot +from zExceptions import Redirect from zope.component import adapter from zope.component import queryMultiAdapter from zope.interface import implementer from zope.publisher.interfaces.browser import IBrowserPublisher -from ZPublisher.BaseRequest import DefaultPublishTraverse from zope.traversing.interfaces import ITraversable -from Products.CMFCore.interfaces import IContentish -from Products.CMFCore.interfaces import ISiteRoot +from ZPublisher.BaseRequest import DefaultPublishTraverse class RESTPublishTraverse(object): @@ -31,7 +32,7 @@ def publishTraverse(self, request, name): def browserDefault(self, request): # Called when we have reached the end of the path - # In our case this means an unamed service + # In our case this means an unnamed service return self.context, (request._rest_service_id,) @@ -41,7 +42,7 @@ class RESTTraverse(RESTPublishTraverse, DefaultPublishTraverse): @implementer(ITraversable) -class MarkAsRESTTraverser(object): +class MarkAsRESTTraverser: """ Traversal adapter for the ``++api++`` namespace. It marks the request as API request. @@ -52,6 +53,18 @@ def __init__(self, context, request): self.request = request def traverse(self, name_ignored, subpath_ignored): + name = "/++api++" + url = self.request.ACTUAL_URL + if url.count(name) > 1: + # Redirect to proper url. + while f"{name}{name}" in url: + url = url.replace(f"{name}{name}", name) + if url.count(name) > 1: + # Something like: .../++api++/something/++api++ + # Return nothing, so a NotFound is raised. + return + # Raise a redirect exception to stop execution of the current request. + raise Redirect(url) mark_as_api_request(self.request, "application/json") return self.context @@ -74,7 +87,7 @@ def __getitem__(self, name): # Delegate key access to the wrapped object return self.context[name] - # MultiHook requries this to be a class attribute + # MultiHook requires this to be a class attribute def __before_publishing_traverse__(self, arg1, arg2=None): bpth = getattr(self.context, "__before_publishing_traverse__", False) if bpth: diff --git a/src/plone/rest/zcml.py b/src/plone/rest/zcml.py index 3e336020..67c108f3 100644 --- a/src/plone/rest/zcml.py +++ b/src/plone/rest/zcml.py @@ -1,13 +1,12 @@ -# -*- coding: utf-8 -*- from AccessControl.class_init import InitializeClass from AccessControl.security import getSecurityInfo from AccessControl.security import protectClass -from Products.Five.browser import BrowserView from plone.rest.cors import CORSPolicy from plone.rest.cors import register_method_for_preflight from plone.rest.interfaces import ICORSPolicy from plone.rest.negotiation import parse_accept_header from plone.rest.negotiation import register_service +from Products.Five.browser import BrowserView from zope.browserpage.metaconfigure import _handle_for from zope.component.zcml import handler from zope.configuration.fields import GlobalInterface @@ -23,48 +22,48 @@ class IService(Interface): """ """ method = TextLine( - title=u"The name of the view that should be the default. " - + u"[get|post|put|delete]", - description=u""" + title="The name of the view that should be the default. " + + "[get|post|put|delete]", + description=""" This name refers to view that should be the view used by default (if no view name is supplied explicitly).""", ) accept = TextLine( - title=u"Acceptable media types", - description=u"""Specifies the media type used for content negotiation. + title="Acceptable media types", + description="""Specifies the media type used for content negotiation. The service is limited to the given media type and only called if the request contains an "Accept" header with the given media type. Multiple media types can be given by separating them with a comma.""", - default=u"application/json", + default="application/json", ) for_ = GlobalObject( - title=u"The interface this view is the default for.", - description=u"""Specifies the interface for which the view is + title="The interface this view is the default for.", + description="""Specifies the interface for which the view is registered. All objects implementing this interface can make use of this view. If this attribute is not specified, the view is available for all objects.""", ) factory = GlobalObject( - title=u"The factory for this service", - description=u"The factory is usually subclass of the Service class.", + title="The factory for this service", + description="The factory is usually subclass of the Service class.", ) name = TextLine( - title=u"The name of the service.", - description=u"""When no name is defined, the service is available at + title="The name of the service.", + description="""When no name is defined, the service is available at the object's absolute URL. When defining a name, the service is available at the object's absolute URL appended with a slash and the service name.""", required=False, - default=u"", + default="", ) layer = GlobalInterface( - title=u"The browser layer for which this service is registered.", - description=u"""Useful for overriding existing services or for making + title="The browser layer for which this service is registered.", + description="""Useful for overriding existing services or for making services available only if a specific add-on has been installed.""", required=False, @@ -72,8 +71,8 @@ class IService(Interface): ) permission = Permission( - title=u"Permission", - description=u"The permission needed to access the service.", + title="Permission", + description="The permission needed to access the service.", required=True, ) @@ -86,9 +85,8 @@ def serviceDirective( for_, permission, layer=IDefaultBrowserLayer, - name=u"", + name="", ): - _handle_for(_context, for_) media_types = parse_accept_header(accept) @@ -137,16 +135,16 @@ class ICORSPolicyDirective(Interface): """Directive for defining CORS policies""" for_ = GlobalObject( - title=u"The interface this CORS policy is for.", - description=u"""Specifies the interface for which the CORS policy is + title="The interface this CORS policy is for.", + description="""Specifies the interface for which the CORS policy is registered. If this attribute is not specified, the CORS policy applies to all objects.""", required=False, ) layer = GlobalInterface( - title=u"The browser layer for which this CORS policy is registered.", - description=u"""Useful for overriding existing policies or for making + title="The browser layer for which this CORS policy is registered.", + description="""Useful for overriding existing policies or for making them available only if a specific add-on has been installed.""", required=False, @@ -154,45 +152,45 @@ class ICORSPolicyDirective(Interface): ) allow_origin = TextLine( - title=u"Origins", - description=u"""Origins that are allowed access to the resource. Either + title="Origins", + description="""Origins that are allowed access to the resource. Either a comma separated list of origins, e.g. "http://example.net, http://mydomain.com" or "*".""", ) allow_methods = TextLine( - title=u"Methods", - description=u"""A comma separated list of HTTP method names that are + title="Methods", + description="""A comma separated list of HTTP method names that are allowed by this CORS policy, e.g. "DELETE,GET,OPTIONS,PATCH,POST,PUT". """, required=False, ) allow_headers = TextLine( - title=u"Headers", - description=u"""A comma separated list of request headers allowed to be + title="Headers", + description="""A comma separated list of request headers allowed to be sent by the client, e.g. "X-My-Header".""", required=False, ) expose_headers = TextLine( - title=u"Exposed Headers", - description=u"""A comma separated list of response headers clients can + title="Exposed Headers", + description="""A comma separated list of response headers clients can access, e.g. "Content-Length,X-My-Header".""", required=False, ) allow_credentials = Bool( - title=u"Support Credentials", - description=u"""Indicates whether the resource supports user + title="Support Credentials", + description="""Indicates whether the resource supports user credentials in the request.""", required=True, default=False, ) max_age = TextLine( - title=u"Max Age", - description=u"""Indicates how long the results of a preflight request + title="Max Age", + description="""Indicates how long the results of a preflight request can be cached.""", required=False, ) @@ -209,7 +207,6 @@ def cors_policy_directive( for_=Interface, layer=IDefaultBrowserLayer, ): - _handle_for(_context, for_) # Create a new policy class and store the CORS policy configuration in @@ -240,7 +237,7 @@ def cors_policy_directive( new_class, (for_, layer), ICORSPolicy, - u"", + "", _context.info, ), ) diff --git a/versions.cfg b/versions.cfg index e1a206c6..ed319548 100644 --- a/versions.cfg +++ b/versions.cfg @@ -14,6 +14,7 @@ Pygments = 2.5.1 plone.recipe.varnish = 1.3 # Code-analysis +black = 23.3.0 plone.recipe.codeanalysis = 3.0.1 coverage = 3.7.1 pep8 = 1.7.1 @@ -51,3 +52,4 @@ pyrsistent = 0.15.7 Click = 7.1.2 httpie = 1.0.3 check-manifest = 0.41 +pyparsing = 2.4.5