Skip to content

Commit

Permalink
Implement token API for Reduct Storage API v1.1 (#56)
Browse files Browse the repository at this point in the history
* implement token API

* update docs
  • Loading branch information
atimin authored Nov 29, 2022
1 parent 6f8975e commit 77b1ce9
Show file tree
Hide file tree
Showing 8 changed files with 205 additions and 2 deletions.
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

0 comments on commit 77b1ce9

Please sign in to comment.