diff --git a/aiocouch/exception.py b/aiocouch/exception.py index e6947bb..04b2533 100644 --- a/aiocouch/exception.py +++ b/aiocouch/exception.py @@ -143,6 +143,12 @@ def raise_for_endpoint( message_input = {} + with suppress(AttributeError): + message_input["reason"] = endpoint.failure_reason + with suppress(AttributeError): + message_input["reason"] = endpoint._remote.failure_reason + message_input["reason"] = message_input.get("reason", exception.message) + with suppress(AttributeError): message_input["id"] = endpoint.id message_input["endpoint"] = endpoint.endpoint diff --git a/aiocouch/remote.py b/aiocouch/remote.py index c5fa91b..9743e65 100644 --- a/aiocouch/remote.py +++ b/aiocouch/remote.py @@ -97,6 +97,7 @@ def __init__( auth = aiohttp.BasicAuth(user, password, "utf-8") if user and password else None headers = {"Cookie": "AuthSession=" + cookie} if cookie else None self._http_session = aiohttp.ClientSession(headers=headers, auth=auth, **kwargs) + self.failure_reason = None async def _get(self, path: str, params: Optional[JsonDict] = None) -> RequestResult: return await self._request("GET", path, params=params) @@ -160,6 +161,12 @@ async def _request( async with self._http_session.request( method, url=f"{self._server}{path}", **kwargs ) as resp: + if not resp.ok: + # Save the reason for later if any + try: + self.failure_reason = (await resp.json())["reason"] + except: + self.failure_reason = None resp.raise_for_status() return ( HTTPResponse(resp), @@ -179,6 +186,12 @@ async def _streamed_request( async with self._http_session.request( method, url=f"{self._server}{path}", **kwargs ) as resp: + if not resp.ok: + # Save the reason for later if any + try: + self.failure_reason = (await resp.json())["reason"] + except: + self.failure_reason = None resp.raise_for_status() async for line in resp.content: @@ -187,6 +200,7 @@ async def _streamed_request( yield json.loads(line) @raises(401, "Invalid credentials") + @raises(403, "Access forbidden: {reason}") async def _all_dbs(self, **params: Any) -> List[str]: _, json = await self._get("/_all_dbs", params) assert not isinstance(json, bytes) @@ -203,12 +217,14 @@ async def close(self) -> None: await asyncio.sleep(0.250 if has_ssl_conn else 0) @raises(401, "Invalid credentials") + @raises(403, "Access forbidden: {reason}") async def _info(self) -> JsonDict: _, json = await self._get("/") assert not isinstance(json, bytes) return json @raises(401, "Authentication failed, check provided credentials.") + @raises(403, "Access forbidden: {reason}") async def _check_session(self) -> RequestResult: return await self._get("/_session") @@ -223,7 +239,7 @@ def endpoint(self) -> str: return f"/{_quote_id(self.id)}" @raises(401, "Invalid credentials") - @raises(403, "Read permission required") + @raises(403, "Access forbidden: {reason}") async def _exists(self) -> bool: try: await self._remote._head(self.endpoint) @@ -235,7 +251,7 @@ async def _exists(self) -> bool: raise e @raises(401, "Invalid credentials") - @raises(403, "Read permission required") + @raises(403, "Access forbidden: {reason}") @raises(404, "Requested database not found ({id})") async def _get(self) -> JsonDict: _, json = await self._remote._get(self.endpoint) @@ -244,6 +260,7 @@ async def _get(self) -> JsonDict: @raises(400, "Invalid database name") @raises(401, "CouchDB Server Administrator privileges required") + @raises(403, "Access forbidden: {reason}") @raises(412, "Database already exists") async def _put(self, **params: Any) -> JsonDict: _, json = await self._remote._put(self.endpoint, params=params) @@ -252,13 +269,14 @@ async def _put(self, **params: Any) -> JsonDict: @raises(400, "Invalid database name or forgotten document id by accident") @raises(401, "CouchDB Server Administrator privileges required") + @raises(403, "Access forbidden: {reason}") @raises(404, "Database doesn't exist or invalid database name ({id})") async def _delete(self) -> None: await self._remote._delete(self.endpoint) @raises(400, "The request provided invalid JSON data or invalid query parameter") @raises(401, "Read permission required") - @raises(403, "Read permission required") + @raises(403, "Access forbidden: {reason}") @raises(404, "Invalid database name") @raises(415, "Bad Content-Type value") async def _bulk_get(self, docs: List[str], **params: Any) -> JsonDict: @@ -270,7 +288,7 @@ async def _bulk_get(self, docs: List[str], **params: Any) -> JsonDict: @raises(400, "The request provided invalid JSON data") @raises(401, "Invalid credentials") - @raises(403, "Write permission required") + @raises(403, "Access forbidden: {reason}") @raises(417, "At least one document was rejected by the validation function") async def _bulk_docs(self, docs: List[JsonDict], **data: Any) -> JsonDict: data["docs"] = docs @@ -280,7 +298,7 @@ async def _bulk_docs(self, docs: List[JsonDict], **data: Any) -> JsonDict: @raises(400, "Invalid request") @raises(401, "Read privilege required for document '{id}'") - @raises(403, "Read permission required") + @raises(403, "Access forbidden: {reason}") @raises(500, "Query execution failed", RuntimeError) async def _find(self, selector: Any, **data: Any) -> JsonDict: data["selector"] = selector @@ -290,6 +308,7 @@ async def _find(self, selector: Any, **data: Any) -> JsonDict: @raises(400, "Invalid request") @raises(401, "Admin permission required") + @raises(403, "Access forbidden: {reason}") @raises(404, "Database not found") @raises(500, "Execution error") async def _index(self, index: JsonDict, **data: Any) -> JsonDict: @@ -299,20 +318,21 @@ async def _index(self, index: JsonDict, **data: Any) -> JsonDict: return json @raises(401, "Invalid credentials") - @raises(403, "Permission required") + @raises(403, "Access forbidden: {reason}") async def _get_security(self) -> JsonDict: _, json = await self._remote._get(f"{self.endpoint}/_security") assert not isinstance(json, bytes) return json @raises(401, "Invalid credentials") - @raises(403, "Permission required") + @raises(403, "Access forbidden: {reason}") async def _put_security(self, doc: JsonDict) -> JsonDict: _, json = await self._remote._put(f"{self.endpoint}/_security", doc) assert not isinstance(json, bytes) return json @generator_raises(400, "Invalid request") + @generator_raises(403, "Access forbidden: {reason}") async def _changes(self, **params: Any) -> AsyncGenerator[JsonDict, None]: if "feed" in params and params["feed"] == "continuous": params.setdefault("heartbeat", True) @@ -329,6 +349,7 @@ async def _changes(self, **params: Any) -> AsyncGenerator[JsonDict, None]: yield result @raises(400, "Invalid database or JSON payload") + @raises(403, "Access forbidden: {reason}") @raises(415, "Bad Content-Type header value") @raises(500, "Internal server error or timeout") async def _purge(self, docs: JsonDict, **params: Any) -> JsonDict: @@ -350,13 +371,13 @@ def endpoint(self) -> str: return f"{self._database.endpoint}/{_quote_id(self.id)}" @raises(401, "Read privilege required for document '{id}'") - @raises(403, "Read privilege required for document '{id}'") + @raises(403, "Access forbidden: {reason}") @raises(404, "Document {id} was not found") async def _head(self) -> None: await self._database._remote._head(self.endpoint) @raises(401, "Read privilege required for document '{id}'") - @raises(403, "Read privilege required for document '{id}'") + @raises(403, "Access forbidden: {reason}") @raises(404, "Document {id} was not found") async def _info(self) -> JsonDict: response, _ = await self._database._remote._head(self.endpoint) @@ -376,7 +397,7 @@ async def _exists(self) -> bool: @raises(400, "The format of the request or revision was invalid") @raises(401, "Read privilege required for document '{id}'") - @raises(403, "Read privilege required for document '{id}'") + @raises(403, "Access forbidden: {reason}") @raises(404, "Document {id} was not found") async def _get(self, **params: Any) -> JsonDict: _, json = await self._database._remote._get(self.endpoint, params) @@ -385,7 +406,7 @@ async def _get(self, **params: Any) -> JsonDict: @raises(400, "The format of the request or revision was invalid") @raises(401, "Write privilege required for document '{id}'") - @raises(403, "Write privilege required for document '{id}'") + @raises(403, "Access forbidden: {reason}") @raises(404, "Specified database or document ID doesn't exists ({endpoint})") @raises( 409, @@ -401,7 +422,7 @@ async def _put( @raises(400, "Invalid request body or parameters") @raises(401, "Write privilege required for document '{id}'") - @raises(403, "Write privilege required for document '{id}'") + @raises(403, "Access forbidden: {reason}") @raises(404, "Specified database or document ID doesn't exists ({endpoint})") @raises( 409, "Specified revision ({rev}) is not the latest for target document '{id}'" @@ -414,7 +435,7 @@ async def _delete(self, rev: str, **params: Any) -> Tuple[HTTPResponse, JsonDict @raises(400, "Invalid request body or parameters") @raises(401, "Read or write privileges required") - @raises(403, "Read or write privileges required") + @raises(403, "Access forbidden: {reason}") @raises( 404, "Specified database, document ID or revision doesn't exists ({endpoint})" ) @@ -444,7 +465,7 @@ def endpoint(self) -> str: return f"{self._document.endpoint}/{_quote_id(self.id)}" @raises(401, "Read privilege required for document '{document_id}'") - @raises(403, "Read privilege required for document '{document_id}'") + @raises(403, "Access forbidden: {reason}") async def _exists(self) -> bool: try: response, _ = await self._document._database._remote._head( @@ -460,7 +481,7 @@ async def _exists(self) -> bool: @raises(400, "Invalid request parameters") @raises(401, "Read privilege required for document '{document_id}'") - @raises(403, "Read privilege required for document '{document_id}'") + @raises(403, "Access forbidden: {reason}") @raises(404, "Document '{document_id}' or attachment '{id}' doesn't exists") async def _get(self, **params: Any) -> bytes: response, data = await self._document._database._remote._get_bytes( @@ -472,7 +493,7 @@ async def _get(self, **params: Any) -> bytes: @raises(400, "Invalid request body or parameters") @raises(401, "Write privilege required for document '{document_id}'") - @raises(403, "Write privilege required for document '{document_id}'") + @raises(403, "Access forbidden: {reason}") @raises(404, "Document '{document_id}' doesn't exists") @raises( 409, "Specified revision {document_rev} is not the latest for target document" @@ -490,7 +511,7 @@ async def _put( @raises(400, "Invalid request body or parameters") @raises(401, "Write privilege required for document '{document_id}'") - @raises(403, "Write privilege required for document '{document_id}'") + @raises(403, "Access forbidden: {reason}") @raises(404, "Specified database or document ID doesn't exists ({endpoint})") @raises( 409, "Specified revision {document_rev} is not the latest for target document" @@ -519,7 +540,7 @@ def endpoint(self) -> str: @raises(400, "Invalid request") @raises(401, "Read privileges required") - @raises(403, "Read privileges required") + @raises(403, "Access forbidden: {reason}") @raises(404, "Specified database, design document or view is missing") async def _get(self, **params: Any) -> JsonDict: _, json = await self._database._remote._get(self.endpoint, params) @@ -528,7 +549,7 @@ async def _get(self, **params: Any) -> JsonDict: @raises(400, "Invalid request") @raises(401, "Write privileges required") - @raises(403, "Write privileges required") + @raises(403, "Access forbidden: {reason}") @raises(404, "Specified database, design document or view is missing") async def _post(self, keys: List[str], **params: Any) -> JsonDict: _, json = await self._database._remote._post( diff --git a/tests/test_exception.py b/tests/test_exception.py index ee21d60..423d2c2 100644 --- a/tests/test_exception.py +++ b/tests/test_exception.py @@ -27,9 +27,8 @@ class CustomError(Exception): class DummyEndpoint: - @property - def endpoint(self) -> str: - return "endpoint" + def __init__(self) -> None: + self.failure_reason = "a reason" @raises(400, "bad thing") async def raise_bad_request(self) -> NoReturn: @@ -39,7 +38,7 @@ async def raise_bad_request(self) -> NoReturn: async def raise_unauthorized(self) -> NoReturn: raise ClientResponseError(cast(RequestInfo, None), (), status=401) - @raises(403, "bad thing") + @raises(403, "Access forbidden: {reason}") async def raise_forbidden(self) -> NoReturn: raise ClientResponseError(cast(RequestInfo, None), (), status=403) @@ -81,7 +80,7 @@ async def test_raises() -> None: with pytest.raises(UnauthorizedError): await dummy.raise_unauthorized() - with pytest.raises(ForbiddenError): + with pytest.raises(ForbiddenError, match="Access forbidden: a reason"): await dummy.raise_forbidden() with pytest.raises(NotFoundError):