Skip to content

Commit

Permalink
Implement group creation
Browse files Browse the repository at this point in the history
  • Loading branch information
ThirVondukr committed Feb 26, 2024
1 parent 5dd5596 commit 647ea62
Show file tree
Hide file tree
Showing 41 changed files with 636 additions and 49 deletions.
23 changes: 23 additions & 0 deletions schema.graphql
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
directive @isAuthenticated on FIELD_DEFINITION

type AltTitle {
id: ID!
language: LanguageEnum!
Expand Down Expand Up @@ -25,6 +27,26 @@ interface Error {
message: String!
}

type Group {
id: ID!
name: String!
}

union GroupCreateError = ValidationErrors | EntityAlreadyExistsError

input GroupCreateInput {
name: String!
}

type GroupCreatePayload {
group: Group
error: GroupCreateError
}

type GroupMutations {
create(input: GroupCreateInput!): GroupCreatePayload! @isAuthenticated
}

type InvalidCredentialsError implements Error {
message: String!
}
Expand Down Expand Up @@ -83,6 +105,7 @@ input MangaTagFilter {
type Mutation {
auth: AuthMutations!
manga: MangaMutations!
groups: GroupMutations!
}

type PagePaginationInfo {
Expand Down
4 changes: 4 additions & 0 deletions src/app/adapters/cli/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from app.db import async_session_factory
from app.db.models import AltTitle, Manga, MangaTag
from lib.types import Language


async def main() -> None:
Expand All @@ -26,6 +27,9 @@ async def main() -> None:
manga.alt_titles = MangaAltTitleFactory.build_batch(
size=random.randint(1, 3), # noqa: S311
)
manga.alt_titles.append(
AltTitle(title=manga.title, language=Language.eng),
)

session.add_all(mangas)
await session.flush()
Expand Down
14 changes: 14 additions & 0 deletions src/app/adapters/graphql/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
from starlette.websockets import WebSocket
from strawberry.asgi import GraphQL

from app.core.di import create_container
from app.core.domain.auth.commands import AuthenticateAccessTokenCommand
from app.core.domain.auth.dto import TokenClaims
from lib.loaders import Dataloaders

from .context import Context
Expand All @@ -15,12 +18,23 @@ async def get_context(
request: Request | WebSocket,
response: Response | None = None,
) -> Context:
claims = await self._authenticate_user(request=request)
return Context(
request=request,
response=response,
loaders=Dataloaders(),
maybe_access_token=claims,
)

async def _authenticate_user(
self,
request: Request | WebSocket,
) -> TokenClaims | None:
token = request.headers.get("authorization", "")
async with create_container().context() as ctx:
command = await ctx.resolve(AuthenticateAccessTokenCommand)
return await command.execute(token=token)


def create_graphql_app() -> GraphQL[Context, None]:
return GraphQLApp(
Expand Down
13 changes: 13 additions & 0 deletions src/app/adapters/graphql/apps/groups/input.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import strawberry

from app.core.domain.groups.dto import GroupCreateDTO


@strawberry.input(name="GroupCreateInput")
class GroupCreateInputGQL:
name: str

def to_dto(self) -> GroupCreateDTO:
return GroupCreateDTO(
name=self.name,
)
48 changes: 48 additions & 0 deletions src/app/adapters/graphql/apps/groups/mutation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from typing import Annotated

import strawberry
from aioinject import Inject
from aioinject.ext.strawberry import inject
from result import Err

from app.adapters.graphql.apps.groups.input import GroupCreateInputGQL
from app.adapters.graphql.apps.groups.payload import GroupCreatePayloadGQL
from app.adapters.graphql.apps.groups.types import GroupGQL
from app.adapters.graphql.context import Info
from app.adapters.graphql.errors import EntityAlreadyExistsErrorGQL
from app.adapters.graphql.extensions import AuthExtension
from app.adapters.graphql.validation import validate_callable
from app.core.domain.groups.commands import GroupCreateCommand
from app.core.errors import EntityAlreadyExistsError


@strawberry.type(name="GroupMutations")
class GroupMutationsGQL:

@strawberry.mutation(extensions=[AuthExtension]) # type: ignore[misc]
@inject
async def create(
self,
command: Annotated[GroupCreateCommand, Inject],
input: GroupCreateInputGQL,
info: Info,
) -> GroupCreatePayloadGQL:
dto = validate_callable(input.to_dto)
if isinstance(dto, Err):
return GroupCreatePayloadGQL(error=dto.err_value)

result = await command.execute(
dto=dto.ok_value,
user=await info.context.user,
)
if isinstance(result, Err):
match result.err_value:
case EntityAlreadyExistsError(): # pragma: no branch
return GroupCreatePayloadGQL(
error=EntityAlreadyExistsErrorGQL(),
)

return GroupCreatePayloadGQL(
group=GroupGQL.from_dto(result.ok_value),
error=None,
)
18 changes: 18 additions & 0 deletions src/app/adapters/graphql/apps/groups/payload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from typing import Annotated

import strawberry

from app.adapters.graphql.apps.groups.types import GroupGQL
from app.adapters.graphql.errors import EntityAlreadyExistsErrorGQL
from app.adapters.graphql.validation import ValidationErrorsGQL

GroupCreateError = Annotated[
ValidationErrorsGQL | EntityAlreadyExistsErrorGQL,
strawberry.union("GroupCreateError"),
]


@strawberry.type(name="GroupCreatePayload")
class GroupCreatePayloadGQL:
group: GroupGQL | None = None
error: GroupCreateError | None
17 changes: 15 additions & 2 deletions src/app/adapters/graphql/apps/groups/types.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
from typing import Self

import strawberry

from app.adapters.graphql.dto import DTOMixin
from app.db.models import Group


@strawberry.type(name="Group")
class GroupGQL:
pass
class GroupGQL(DTOMixin[Group]):
id: strawberry.ID
name: str

@classmethod
def from_dto(cls, model: Group) -> Self:
return cls(
id=strawberry.ID(str(model.id)),
name=model.name,
)
17 changes: 16 additions & 1 deletion src/app/adapters/graphql/context.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,36 @@
import dataclasses
from collections.abc import Awaitable
from functools import cached_property
from typing import TypeVar

from starlette.requests import Request
from starlette.responses import Response
from starlette.websockets import WebSocket
from strawberry.types import Info as StrawberryInfo

from app.core.di import create_container
from app.core.domain.auth.dto import TokenClaims
from app.core.domain.auth.utils import UserGetter
from app.db.models import User
from lib.loaders import Dataloaders

T = TypeVar("T")


@dataclasses.dataclass(slots=True, kw_only=True)
@dataclasses.dataclass(kw_only=True)
class Context:
request: Request | WebSocket
response: Response | None
loaders: Dataloaders

maybe_access_token: TokenClaims | None

@cached_property
def user(self) -> Awaitable[User]:
return UserGetter(
container=create_container(),
token=self.maybe_access_token,
)


Info = StrawberryInfo[Context, None]
22 changes: 22 additions & 0 deletions src/app/adapters/graphql/extensions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from typing import Any

from strawberry import BasePermission
from strawberry.permission import PermissionExtension

from app.adapters.graphql.context import Info


class IsAuthenticated(BasePermission):
message = "Not Authenticated"
error_extensions = {"code": "UNAUTHORIZED"} # noqa: RUF012

def has_permission(
self,
source: Any, # noqa: ARG002, ANN401
info: Info,
**kwargs: Any, # noqa: ARG002, ANN401
) -> bool:
return info.context.maybe_access_token is not None


AuthExtension = PermissionExtension([IsAuthenticated()])
5 changes: 5 additions & 0 deletions src/app/adapters/graphql/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from strawberry.tools import merge_types

from app.adapters.graphql.apps.auth.mutation import AuthMutationsGQL
from app.adapters.graphql.apps.groups.mutation import GroupMutationsGQL
from app.adapters.graphql.apps.manga.mutation import MangaMutationsGQL
from app.adapters.graphql.apps.manga.query import MangaQuery
from app.core.di import create_container
Expand All @@ -25,6 +26,10 @@ async def auth(self) -> AuthMutationsGQL:
async def manga(self) -> MangaMutationsGQL:
return MangaMutationsGQL()

@strawberry.field
async def groups(self) -> GroupMutationsGQL:
return GroupMutationsGQL()


schema = Schema(
query=Query,
Expand Down
3 changes: 2 additions & 1 deletion src/app/core/di/_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@
from app.settings import AuthSettings, DatabaseSettings, SentrySettings
from lib.settings import get_settings

from ._modules import auth, database, manga, users
from ._modules import auth, database, groups, manga, users

modules: Iterable[Iterable[Provider[Any]]] = [
auth.providers,
database.providers,
groups.providers,
manga.providers,
users.providers,
]
Expand Down
6 changes: 5 additions & 1 deletion src/app/core/di/_modules/auth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import aioinject
from passlib.context import CryptContext

from app.core.domain.auth.commands import SignInCommand
from app.core.domain.auth.commands import (
AuthenticateAccessTokenCommand,
SignInCommand,
)
from app.core.domain.auth.services import AuthService, TokenService
from app.settings import AuthSettings
from lib.types import Providers
Expand All @@ -18,4 +21,5 @@ def _create_crypt_context(auth_settings: AuthSettings) -> CryptContext:
aioinject.Singleton(_create_crypt_context),
aioinject.Scoped(AuthService),
aioinject.Scoped(SignInCommand),
aioinject.Scoped(AuthenticateAccessTokenCommand),
]
12 changes: 12 additions & 0 deletions src/app/core/di/_modules/groups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from aioinject import Scoped

from app.core.domain.groups.commands import GroupCreateCommand
from app.core.domain.groups.repositories import GroupRepository
from app.core.domain.groups.services import GroupService
from lib.types import Providers

providers: Providers = [
Scoped(GroupRepository),
Scoped(GroupService),
Scoped(GroupCreateCommand),
]
26 changes: 25 additions & 1 deletion src/app/core/domain/auth/commands.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from app.core.domain.auth.dto import SignInDTO, UserAuthResultDTO
from result import Err

from app.core.domain.auth.dto import SignInDTO, TokenClaims, UserAuthResultDTO
from app.core.domain.auth.services import AuthService, TokenService


Expand All @@ -21,3 +23,25 @@ async def execute(self, dto: SignInDTO) -> UserAuthResultDTO | None:
access_token=self._token_service.create_access_token(user=user),
refresh_token=self._token_service.create_refresh_token(user=user),
)


class AuthenticateAccessTokenCommand:
def __init__(self, token_service: TokenService) -> None:
self._token_service = token_service

async def execute(self, token: str | None) -> TokenClaims | None:
if not token or not token.startswith("Bearer "):
return None

token = token.removeprefix("Bearer ")

claims = self._token_service.decode(token=token)
if isinstance(claims, Err): # pragma: no cover
return None

if ( # pragma: no cover
claims.ok_value.token_type != "access" # noqa: S105
):
return None

return claims.ok_value
8 changes: 8 additions & 0 deletions src/app/core/domain/auth/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import dataclasses

from jwt import PyJWTError


@dataclasses.dataclass
class TokenDecodeError:
error: PyJWTError
Loading

0 comments on commit 647ea62

Please sign in to comment.