Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement token API for Reduct Storage API v1.1 #56

Merged
merged 2 commits into from
Nov 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Support Python 3.7, [PR-53](https://github.com/reduct-storage/reduct-py/pull/53)
- `Client.get_full_info()` to get full information about a
bucket, [pr-55](https://github.com/reduct-storage/reduct-py/pull/55)
- Implement token API for Reduct Storage API v1.1, [PR-56](https://github.com/reduct-storage/reduct-py/pull/56)

### Changed:

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ Asynchronous HTTP client for [Reduct Storage](https://reduct-storage.dev) writte

## Features

* Support Reduct Storage HTTP API v1.0
* Based on aiohttp
* Support [Reduct Storage HTTP API v1.1](https://docs.reduct-storage.dev/http-api)
* Based on aiohttp and pydantic

## Install

Expand Down
5 changes: 5 additions & 0 deletions docs/api/token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
::: reduct.Token

::: reduct.FullTokenInfo

::: reduct.Permissions
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ nav:
- BucketInfo: docs/api/bucket_info.md
- BucketSettings: docs/api/bucket_settings.md
- EntryInfo: docs/api/entry_info.md
- Token: docs/api/token.md
- Reduct Storage: https://reduct-storage.dev

repo_name: reduct-storage/reduct-py
Expand Down
3 changes: 3 additions & 0 deletions pkg/reduct/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
Client,
ServerInfo,
BucketList,
Token,
Permissions,
FullTokenInfo,
)

from reduct.error import ReductError
97 changes: 97 additions & 0 deletions pkg/reduct/client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Main client code"""
from datetime import datetime
from typing import Optional, List

from pydantic import BaseModel
Expand Down Expand Up @@ -46,6 +47,49 @@ class BucketList(BaseModel):
buckets: List[BucketInfo]


class Permissions(BaseModel):
"""Token permission"""

full_access: bool
"""full access to manage buckets and tokens"""

read: Optional[List[str]]
"""list of buckets with read access"""

write: Optional[List[str]]
"""list of buckets with write access"""


class Token(BaseModel):
"""Token for authentication"""

name: str
"""name of token"""

created_at: datetime
"""creation time of token"""


class FullTokenInfo(Token):
"""Full information about token with permissions"""

permissions: Permissions
"""permissions of token"""


class TokenList(BaseModel):
"""List of tokens"""

tokens: List[Token]


class TokenCreateResponse(BaseModel):
"""Response from creating a token"""

value: str
"""token for authentication"""


class Client:
"""HTTP Client for Reduct Storage HTTP API"""

Expand Down Expand Up @@ -131,3 +175,56 @@ async def create_bucket(
raise err

return Bucket(name, self._http)

async def get_token_list(self) -> List[Token]:
"""
Get a list of all tokens
Returns:
List[Token]
Raises:
ReductError: if there is an HTTP error
"""
return TokenList.parse_raw(
await self._http.request_all("GET", "/tokens")
).tokens

async def get_token(self, name: str) -> FullTokenInfo:
"""
Get a token by name
Args:
name: name of the token
Returns:
Token
Raises:
ReductError: if there is an HTTP error
"""
return FullTokenInfo.parse_raw(
await self._http.request_all("GET", f"/tokens/{name}")
)

async def create_token(self, name: str, permissions: Permissions) -> str:
"""
Create a new token
Args:
name: name of the token
permissions: permissions for the token
Returns:
str: token value
Raises:
ReductError: if there is an HTTP error
"""
return TokenCreateResponse.parse_raw(
await self._http.request_all(
"POST", f"/tokens/{name}", data=permissions.json()
)
).value

async def remove_token(self, name: str) -> None:
"""
Delete a token
Args:
name: name of the token
Raises:
ReductError: if there is an HTTP error
"""
await self._http.request_all("DELETE", f"/tokens/{name}")
82 changes: 82 additions & 0 deletions tests/client_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import List

import pytest
import pytest_asyncio

from reduct import (
Client,
Expand All @@ -11,7 +12,23 @@
BucketInfo,
QuotaType,
BucketSettings,
Permissions,
)
from .conftest import requires_env


@pytest_asyncio.fixture(name="with_token")
async def _create_token(client):
"""Create a token for tests"""
_ = await client.create_token(
"test-token",
Permissions(full_access=True, read=["bucket-1"], write=["bucket-2"]),
)
yield "test-token"
try:
await client.remove_token("test-token")
except ReductError:
pass


@pytest.mark.asyncio
Expand Down Expand Up @@ -129,3 +146,68 @@ def test__exception_formatting():
"""Check the output formatting of raised exceptions"""
with pytest.raises(ReductError, match="Status 404: Not Found"):
raise ReductError(404, '{"detail":"Not Found"}')


@requires_env("RS_API_TOKEN")
@pytest.mark.asyncio
async def test__create_token(client):
"""Should create a token"""
token = await client.create_token(
"test-token",
Permissions(full_access=True, read=["bucket-1"], write=["bucket-2"]),
)
assert "test-token-" in token


@requires_env("RS_API_TOKEN")
@pytest.mark.asyncio
async def test__create_token_with_error(client, with_token):
"""Should raise an error, if token exists"""
with pytest.raises(
ReductError, match="Status 409: Token 'test-token' already exists"
):
await client.create_token(
with_token, Permissions(full_access=True, read=[], write=[])
)


@requires_env("RS_API_TOKEN")
@pytest.mark.asyncio
async def test__get_token(client, with_token):
"""Should get a token by name"""
token = await client.get_token(with_token)
assert token.name == with_token
assert token.permissions.dict() == {
"full_access": True,
"read": ["bucket-1"],
"write": ["bucket-2"],
}


@requires_env("RS_API_TOKEN")
@pytest.mark.asyncio
async def test__get_token_with_error(client):
"""Should raise an error, if token doesn't exist"""
with pytest.raises(ReductError, match="Status 404: Token 'NOTEXIST' doesn't exist"):
await client.get_token("NOTEXIST")


@requires_env("RS_API_TOKEN")
@pytest.mark.asyncio
async def test__list_tokens(client, with_token):
"""Should list all tokens"""
tokens = await client.get_token_list()
assert len(tokens) == 2
assert tokens[0].name == "init-token"
assert tokens[1].name == with_token


@requires_env("RS_API_TOKEN")
@pytest.mark.asyncio
async def test__remove_token(client, with_token):
"""Should delete a token"""
await client.remove_token(with_token)
with pytest.raises(
ReductError, match="Status 404: Token 'test-token' doesn't exist"
):
await client.get_token(with_token)
14 changes: 14 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@
from reduct import Client, Bucket


def requires_env(key):
"""Skip test if environment variable is not set"""
env = os.environ.get(key)

return pytest.mark.skipif(
env is None or env == "",
reason=f"Not suitable environment {key} for current test",
)


@pytest.fixture(name="url")
def _url() -> str:
return "http://127.0.0.1:8383"
Expand All @@ -21,6 +31,10 @@ async def _make_client(url):
bucket = await client.get_bucket(info.name)
await bucket.remove()

for token in await client.get_token_list():
if token.name != "init-token":
await client.remove_token(token.name)

yield client


Expand Down