Skip to content

Commit a6bc839

Browse files
committed
Add manager interfaces and implementations
1 parent 82e9855 commit a6bc839

File tree

9 files changed

+614
-0
lines changed

9 files changed

+614
-0
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from __future__ import annotations
2+
3+
import typing
4+
5+
import chalicelib.template_manager.__interface__ as template_mgr_interface
6+
import chalicelib.util.type_util as type_util
7+
import pydantic
8+
9+
10+
class BaseSendRequest(pydantic.BaseModel):
11+
template_code: str
12+
shared_context: type_util.ContextType
13+
personalized_context: dict[str, type_util.ContextType]
14+
15+
16+
class BaseSendRawRequest(pydantic.BaseModel):
17+
personalized_content: dict[str, type_util.ContextType]
18+
19+
20+
SendRequestType = typing.TypeVar("SendRequestType", bound=BaseSendRequest)
21+
SendRawRequestType = typing.TypeVar("SendRawRequestType", bound=BaseSendRawRequest)
22+
TemplateManagerType = typing.TypeVar("TemplateManagerType", bound=template_mgr_interface.TemplateManagerInterface)
23+
24+
25+
class SendManagerInterface[SendRequestType, SendRawRequestType, TemplateManagerType](typing.Protocol):
26+
CAN_SEND_RAW_MESSAGE: typing.ClassVar[bool] = False
27+
28+
@property
29+
def initialized(self) -> bool: ...
30+
31+
@property
32+
def template_manager(self) -> TemplateManagerType: ...
33+
34+
def send(self, request: SendRequestType) -> dict[str, str | None]: ...
35+
36+
def send_raw(self, request: SendRawRequestType) -> dict[str, str | None]: ...
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import traceback
2+
3+
import botocore.exceptions
4+
import chalicelib.aws_resource as aws_resource_module
5+
import chalicelib.send_manager.__interface__ as sendmgr_interface
6+
import chalicelib.template_manager.aws_ses as aws_ses_template_mgr
7+
8+
9+
class AWSSESSendRawRequest(sendmgr_interface.BaseSendRawRequest):
10+
personalized_content: dict[str, aws_ses_template_mgr.EmailTemplateManager.TemplateStructure]
11+
12+
13+
class AWSSESSendManager(
14+
sendmgr_interface.SendManagerInterface[
15+
sendmgr_interface.BaseSendRequest, AWSSESSendRawRequest, aws_ses_template_mgr.EmailTemplateManager
16+
]
17+
):
18+
CAN_SEND_RAW_MESSAGE: bool = True
19+
20+
@property
21+
def initialized(self) -> bool:
22+
return True
23+
24+
@property
25+
def template_manager(self) -> aws_ses_template_mgr.EmailTemplateManager:
26+
return aws_ses_template_mgr.email_template_manager
27+
28+
def _send_email(self, from_: str, to_: str, title: str, body: str) -> str:
29+
try:
30+
return aws_resource_module.ses_client.send_email(
31+
Source=from_,
32+
# Because if you send it to multiple people at once,
33+
# the e-mail addresses of the people you send with might be exposed to each other.
34+
# So, we send it one by one.
35+
Destination={"ToAddresses": [to_]},
36+
Message={
37+
"Subject": {"Charset": "UTF-8", "Data": title},
38+
"Body": {"Html": {"Charset": "UTF-8", "Data": body}},
39+
},
40+
)["MessageId"]
41+
except Exception as e:
42+
err_tb = "\n".join(traceback.format_exception(e))
43+
if isinstance(e, botocore.exceptions.HTTPClientError):
44+
return e.response.get("Error", {}).get("Message", err_tb)
45+
return err_tb
46+
47+
def send(self, request: sendmgr_interface.SendRequestType) -> dict[str, str]:
48+
result: dict[str, str] = {}
49+
50+
for receiver, personalized_context in request.personalized_context.items():
51+
context = request.shared_context | personalized_context
52+
render_result = self.template_manager.render(code=request.template_code, context=context)
53+
result[receiver] = self._send_email(
54+
from_=render_result["from_address"],
55+
to_=receiver,
56+
title=render_result["title"],
57+
body=render_result["body"],
58+
)
59+
60+
return result
61+
62+
def send_raw(self, request: AWSSESSendRawRequest) -> dict[str, str | None]:
63+
content_set = request.personalized_content.items()
64+
return {r: self._send_email(from_=c.from_, to_=r, title=c.title, body=c.body) for r, c in content_set}
65+
66+
67+
aws_ses_send_manager = AWSSESSendManager()
68+
send_manager_patterns: dict[str, sendmgr_interface.SendManagerInterface] = {
69+
"aws_ses": aws_ses_send_manager,
70+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import functools
2+
import typing
3+
4+
import chalicelib.config as config_module
5+
import chalicelib.external_api.toast_alimtalk as toast_alimtalk_client
6+
import chalicelib.send_manager.__interface__ as sendmgr_interface
7+
import chalicelib.template_manager.toast_alimtalk as toast_alimtalk_template_mgr
8+
9+
10+
class ToastAlimtalkSendRequest(sendmgr_interface.BaseSendRequest):
11+
def as_request_payload(self) -> toast_alimtalk_client.MsgSendRequest:
12+
return toast_alimtalk_client.MsgSendRequest(
13+
senderKey=config_module.config.toast.sender_key.get_secret_value(),
14+
templateCode=self.template_code,
15+
recipientList=[
16+
toast_alimtalk_client.MsgSendRequest.Recipient(
17+
recipientNo=send_to,
18+
templateParameter=personalized_data,
19+
)
20+
for send_to, personalized_data in self.personalized_context.items()
21+
],
22+
)
23+
24+
25+
class ToastAlimtalkSendManager(
26+
sendmgr_interface.SendManagerInterface[
27+
ToastAlimtalkSendRequest,
28+
sendmgr_interface.BaseSendRawRequest,
29+
toast_alimtalk_template_mgr.ToastAlimtalkTemplateManager,
30+
]
31+
):
32+
CAN_SEND_RAW_MESSAGE: bool = True
33+
34+
@property
35+
def initialized(self) -> bool:
36+
return config_module.config.toast.is_configured()
37+
38+
@property
39+
def template_manager(self) -> toast_alimtalk_template_mgr.ToastAlimtalkTemplateManager:
40+
return toast_alimtalk_template_mgr.toast_alimtalk_template_manager
41+
42+
@functools.cached_property
43+
def client(self) -> toast_alimtalk_client.ToastAlimTalkClient:
44+
return toast_alimtalk_client.ToastAlimTalkClient()
45+
46+
def send(self, request: ToastAlimtalkSendRequest) -> dict[str, str]:
47+
request_payload = request.as_request_payload()
48+
return {r.recipientNo: r.resultCode for r in self.client.send_alimtalk(request_payload).message.sendResults}
49+
50+
def send_raw(self, request: sendmgr_interface.BaseSendRawRequest) -> typing.NoReturn:
51+
raise NotImplementedError("Toast 알림톡은 Raw 메시지를 보낼 수 없습니다.")
52+
53+
54+
toast_alimtalk_send_manager = ToastAlimtalkSendManager()
55+
send_manager_patterns: dict[str, sendmgr_interface.SendManagerInterface] = {
56+
"toast_alimtalk": toast_alimtalk_send_manager,
57+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import pathlib
2+
import typing
3+
4+
import chalicelib.template_manager.__interface__ as template_mgr_interface
5+
import chalicelib.util.import_util as import_util
6+
7+
_TemplateManagerCollectionType = dict[str, template_mgr_interface.TemplateManagerInterface]
8+
template_managers: _TemplateManagerCollectionType = {}
9+
10+
for _path in pathlib.Path(__file__).parent.glob("*.py"):
11+
if _path.stem.startswith("__") or not (
12+
_patterns := typing.cast(
13+
_TemplateManagerCollectionType,
14+
getattr(
15+
import_util.load_module(_path),
16+
"template_manager_patterns",
17+
None,
18+
),
19+
)
20+
):
21+
continue
22+
23+
if _duplicated := template_managers.keys() & _patterns.keys():
24+
raise ValueError(f"Template manager {_duplicated} is already registered")
25+
26+
template_managers.update(_patterns)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from __future__ import annotations
2+
3+
import dataclasses
4+
import functools
5+
import json
6+
import typing
7+
8+
import chalicelib.aws_resource as aws_resource
9+
import chalicelib.template_manager.__interface__ as template_mgr_interface
10+
import chalicelib.util.type_util as type_util
11+
import jinja2
12+
import jinja2.meta
13+
import jinja2.nodes
14+
import pydantic
15+
16+
17+
class TemplateInformation(pydantic.BaseModel):
18+
code: str
19+
template: type_util.TemplateType
20+
21+
@pydantic.computed_field # type: ignore[misc]
22+
@property
23+
def template_variables(self) -> set[str]:
24+
# From https://stackoverflow.com/a/77363330
25+
template = jinja2.Environment(autoescape=True).parse(source=json.dumps(self.template))
26+
return jinja2.meta.find_undeclared_variables(ast=template)
27+
28+
29+
TemplateStructureType = typing.TypeVar("TemplateStructureType", bound=pydantic.BaseModel)
30+
31+
32+
class TemplateManagerInterface(typing.Protocol[TemplateStructureType]):
33+
template_structure_cls: type[TemplateStructureType] | type[str] | None = None
34+
35+
def check_template_valid(self, template_data: type_util.TemplateType) -> bool:
36+
if not self.template_structure_cls or self.template_structure_cls == str:
37+
return True
38+
typing.cast(type[TemplateStructureType], self.template_structure_cls).model_validate(template_data)
39+
return True
40+
41+
def list(self) -> list[TemplateInformation]: ...
42+
43+
def retrieve(self, code: str) -> TemplateInformation | None: ...
44+
45+
def create(self, code: str, template_data: type_util.TemplateType) -> TemplateInformation: ...
46+
47+
def update(self, code: str, template_data: type_util.TemplateType) -> TemplateInformation: ...
48+
49+
def delete(self, code: str) -> None: ...
50+
51+
def render(self, code: str, context: type_util.ContextType) -> type_util.TemplateType: ...
52+
53+
54+
S3TemplateStructureType = typing.TypeVar("S3TemplateStructureType", bound=pydantic.BaseModel)
55+
56+
57+
@dataclasses.dataclass
58+
class S3ResourceTemplateManager(template_mgr_interface.TemplateManagerInterface):
59+
resource: typing.ClassVar[aws_resource.S3ResourcePath]
60+
61+
def list(self) -> list[template_mgr_interface.TemplateInformation]:
62+
return [self.retrieve(code=f.split(sep=".")[0]) for f in self.resource.list_objects(filter_by_extension=True)]
63+
64+
@functools.lru_cache # noqa: B019
65+
def retrieve(self, code: str) -> template_mgr_interface.TemplateInformation:
66+
template_body: str = self.resource.download(code=code).decode(encoding="utf-8")
67+
return template_mgr_interface.TemplateInformation(code=code, template=template_body)
68+
69+
def create(self, code: str, template_data: type_util.TemplateType) -> template_mgr_interface.TemplateInformation:
70+
self.check_template_valid(template_data=template_data)
71+
self.resource.upload(code=code, content=template_data)
72+
return template_mgr_interface.TemplateInformation(code=code, template=template_data)
73+
74+
def update(self, code: str, template_data: type_util.TemplateType) -> template_mgr_interface.TemplateInformation:
75+
return self.create(code=code, template_data=template_data)
76+
77+
def delete(self, code: str) -> None:
78+
self.resource.delete(code=code)
79+
80+
def render(self, code: str, context: type_util.ContextType) -> type_util.TemplateType:
81+
return json.loads(jinja2.Template(source=json.dumps(obj=self.retrieve(code=code).template)).render(context))
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import chalicelib.aws_resource as aws_resource
2+
import chalicelib.template_manager.__interface__ as template_mgr_interface
3+
import pydantic
4+
5+
6+
class EmailTemplateManager(template_mgr_interface.S3ResourceTemplateManager):
7+
class TemplateStructure(pydantic.BaseModel):
8+
from_: pydantic.EmailStr
9+
title: str
10+
body: str
11+
12+
template_structure_cls = TemplateStructure
13+
resource = aws_resource.S3ResourcePath.email_template
14+
15+
16+
email_template_manager = EmailTemplateManager()
17+
template_manager_patterns: dict[str, template_mgr_interface.TemplateManagerInterface] = {
18+
"email": email_template_manager,
19+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import chalicelib.aws_resource as aws_resource
2+
import chalicelib.template_manager.__interface__ as template_mgr_interface
3+
import pydantic
4+
5+
6+
class FirebaseTemplateManager(template_mgr_interface.S3ResourceTemplateManager):
7+
class TemplateStructure(pydantic.BaseModel):
8+
title: str
9+
body: str
10+
11+
template_structure_cls = TemplateStructure
12+
resource = aws_resource.S3ResourcePath.firebase_template
13+
14+
15+
firebase_cloudmessaging_template_manager = FirebaseTemplateManager()
16+
template_manager_patterns: dict[str, template_mgr_interface.TemplateManagerInterface] = {
17+
"firebase_cloudmessaging": firebase_cloudmessaging_template_manager,
18+
}

0 commit comments

Comments
 (0)