├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENCE ├── README.md ├── fastapi_event ├── __init__.py ├── base.py ├── exceptions.py ├── handler.py ├── listener.py └── middleware.py ├── release.sh ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── events.py ├── test_handler.py ├── test_listener.py ├── test_middleware.py └── test_validator.py /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: fastapi-event 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | 9 | jobs: 10 | build: 11 | name: CI 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | 17 | - name: Setup Python 3.8 18 | uses: actions/setup-python@v1 19 | with: 20 | python-version: 3.8 21 | 22 | - name: Install pytest 23 | run: pip3 install pytest 24 | 25 | - name: Install pytest-asyncio 26 | run: pip3 install pytest-asyncio 27 | 28 | - name: Install fastapi 29 | run: pip3 install fastapi 30 | 31 | - name: Install requests 32 | run: pip3 install requests 33 | 34 | - name: Testing 35 | run: pytest 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # Django 9 | expression/static/* 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # pyenv 81 | .python-version 82 | 83 | # celery beat schedule file 84 | celerybeat-schedule 85 | 86 | # SageMath parsed files 87 | *.sage.py 88 | 89 | # Environments 90 | .venv 91 | venv/ 92 | env.bak/ 93 | venv.bak/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | .mypy_cache/ 107 | ### Example user template template 108 | ### Example user template 109 | 110 | # IntelliJ project files 111 | .idea 112 | *.iml 113 | out 114 | gen -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastapi-event 2 | [![license]](/LICENSE) 3 | [![pypi]](https://pypi.org/project/fastapi-event/) 4 | [![pyversions]](http://pypi.python.org/pypi/fastapi-event) 5 | [![Downloads](https://pepy.tech/badge/fastapi-event)](https://pepy.tech/project/fastapi-event) 6 | 7 | --- 8 | 9 | fastapi-event is event dispatcher for FastAPI framework. 10 | 11 | ## Installation 12 | 13 | ```python 14 | pip3 install fastapi-event 15 | ``` 16 | 17 | ## Usage 18 | 19 | ### Make Event 20 | 21 | ```python 22 | from fastapi_event import BaseEvent 23 | 24 | 25 | class TestEvent(BaseEvent): 26 | async def run(self, parameter=None): 27 | ... 28 | ``` 29 | 30 | Inherit `BaseEvent` and override `run()` method. 31 | 32 | ```python 33 | from fastapi_event import BaseEvent 34 | 35 | 36 | class FirstEvent(BaseEvent): 37 | ORDER = 1 # HERE(Optional) 38 | 39 | async def run(self, parameter=None): 40 | ... 41 | 42 | 43 | class SecondEvent(BaseEvent): 44 | ORDER = 2 # HERE(Optional) 45 | 46 | async def run(self, parameter=None): 47 | ... 48 | ``` 49 | 50 | If you want to determine the order between events, specify `ORDER` in your event. 51 | 52 | Then, regardless of the order in which the events are stored, they will be executed in the order specified in `ORDER` variable. 53 | 54 | However, `ORDER` does not work when `run_at_once=True`. 55 | 56 | ### Parameter(optional) 57 | 58 | ```python 59 | from pydantic import BaseModel 60 | 61 | 62 | class TestEventParameter(BaseModel): 63 | id: str 64 | pw: str 65 | ``` 66 | 67 | In case of need parameter, you have to inherit `BaseModel` and set fields. 68 | 69 | ### Middleware 70 | 71 | ```python 72 | from fastapi import FastAPI 73 | from fastapi_event import EventHandlerMiddleware 74 | 75 | app = FastAPI() 76 | app.add_middleware(EventHandlerMiddleware) 77 | ``` 78 | 79 | ### EventListener 80 | 81 | ```python 82 | from fastapi_event import EventListener 83 | 84 | 85 | @EventListener() 86 | async def test(): 87 | ... 88 | ``` 89 | 90 | Set `@EventListener()` decorator on the function that emits the event. 91 | 92 | ```python 93 | @EventListener(run_at_once=False) 94 | ``` 95 | 96 | If you pass `run_at_once=False`, it will execute in the order in which `store()` is called. (or according to the `ORDER` variable defined in the event) 97 | 98 | Otherwise, it will execute through `asyncio.gather()` to run at once. 99 | 100 | ### Store event 101 | 102 | ```python 103 | from fastapi_event import EventListener, event_handler 104 | 105 | 106 | @EventListener() 107 | async def test(): 108 | await event_handler.store( 109 | event=TestEvent, 110 | parameter=TestParameter(id="hide", pw="hide"), # Optional 111 | ) 112 | ``` 113 | 114 | Store your event to handler via `store()` method. (parameter is optional) 115 | 116 | An event will be emitted after the function has finished executing. 117 | 118 | [license]: https://img.shields.io/badge/License-Apache%202.0-blue.svg 119 | [pypi]: https://img.shields.io/pypi/v/fastapi-event 120 | [pyversions]: https://img.shields.io/pypi/pyversions/fastapi-event 121 | -------------------------------------------------------------------------------- /fastapi_event/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi_event.base import BaseEvent 2 | from fastapi_event.handler import event_handler 3 | from fastapi_event.listener import EventListener 4 | from fastapi_event.middleware import EventHandlerMiddleware 5 | 6 | __all__ = [ 7 | "event_handler", 8 | "BaseEvent", 9 | "EventListener", 10 | "EventHandlerMiddleware", 11 | ] 12 | -------------------------------------------------------------------------------- /fastapi_event/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Type, Union 3 | 4 | from pydantic import BaseModel 5 | 6 | 7 | class BaseEvent(ABC): 8 | ORDER = None 9 | 10 | @abstractmethod 11 | async def run(self, parameter: Union[Type[BaseModel], None] = None) -> None: 12 | pass 13 | -------------------------------------------------------------------------------- /fastapi_event/exceptions.py: -------------------------------------------------------------------------------- 1 | class InvalidEventTypeException(Exception): 2 | def __init__(self): 3 | super().__init__("Event must inherit BaseEvent") 4 | 5 | 6 | class InvalidParameterTypeException(Exception): 7 | def __init__(self): 8 | super().__init__("Parameter must inherit BaseModel") 9 | 10 | 11 | class EmptyContextException(Exception): 12 | def __init__(self): 13 | super().__init__("Event context is empty. check if middleware configured well") 14 | 15 | 16 | class ParameterCountException(Exception): 17 | def __init__(self): 18 | super().__init__("Event has too many parameter") 19 | 20 | 21 | class RequiredParameterException(Exception): 22 | def __init__(self, cls_name): 23 | super().__init__(f"`{cls_name}` event require parameter") 24 | 25 | 26 | class InvalidOrderTypeException(Exception): 27 | def __init__(self): 28 | super().__init__("ORDER must be type of `int`") 29 | -------------------------------------------------------------------------------- /fastapi_event/handler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import inspect 3 | from contextvars import ContextVar 4 | from pydantic import BaseModel 5 | from typing import Type, Dict, Union, Optional, List 6 | 7 | from fastapi_event.base import BaseEvent 8 | from fastapi_event.exceptions import ( 9 | InvalidEventTypeException, 10 | InvalidParameterTypeException, 11 | EmptyContextException, 12 | RequiredParameterException, 13 | ParameterCountException, 14 | InvalidOrderTypeException, 15 | ) 16 | 17 | _handler_context: ContextVar[Optional, "EventHandler"] = ContextVar( 18 | "_handler_context", 19 | default=None, 20 | ) 21 | 22 | 23 | class EventAndParameter(BaseModel): 24 | event: Type[BaseEvent] 25 | parameter: Optional[BaseModel] = None 26 | 27 | 28 | class EventHandlerValidator: 29 | EVENT_PARAMETER_COUNT = 2 30 | 31 | async def validate( 32 | self, event: Type[BaseEvent], parameter: BaseModel = None, 33 | ) -> None: 34 | if not issubclass(event, BaseEvent): 35 | raise InvalidEventTypeException 36 | 37 | if parameter and not isinstance(parameter, BaseModel): 38 | raise InvalidParameterTypeException 39 | 40 | signature = inspect.signature(event.run) 41 | func_parameters = signature.parameters 42 | if len(func_parameters) != self.EVENT_PARAMETER_COUNT: 43 | raise ParameterCountException 44 | 45 | base_parameter = func_parameters.get("parameter") 46 | if base_parameter.default is not None and not parameter: 47 | raise RequiredParameterException( 48 | cls_name=base_parameter.__class__.__name__, 49 | ) 50 | 51 | 52 | class EventHandler: 53 | def __init__(self, validator: EventHandlerValidator): 54 | self.events: Dict[Type[BaseEvent], Union[BaseModel, None]] = {} 55 | self.validator = validator 56 | 57 | async def store(self, event: Type[BaseEvent], parameter: BaseModel = None) -> None: 58 | await self.validator.validate(event=event, parameter=parameter) 59 | self.events[event] = parameter 60 | 61 | async def _publish(self, run_at_once: bool = True) -> None: 62 | if run_at_once is True: 63 | await self._run_at_once() 64 | else: 65 | await self._run_sequentially() 66 | 67 | self.events.clear() 68 | 69 | async def _run_at_once(self) -> None: 70 | futures = [] 71 | event: Type[BaseEvent] 72 | for event, parameter in self.events.items(): 73 | task = asyncio.create_task(event().run(parameter=parameter)) 74 | futures.append(task) 75 | 76 | await asyncio.gather(*futures) 77 | 78 | async def _run_sequentially(self) -> None: 79 | event_maps = await self._get_sorted_event_maps() 80 | keys = await self._get_sorted_keys(maps=event_maps) 81 | 82 | for key in keys: 83 | info = event_maps.get(key) 84 | for each in info: 85 | await each.event().run(parameter=each.parameter) 86 | 87 | async def _get_sorted_keys( 88 | self, maps: Dict[Optional[int], List[EventAndParameter]] 89 | ) -> List[Optional[int]]: 90 | keys: List[Optional[int]] = sorted( 91 | [key for key in maps.keys() if key is not None] 92 | ) 93 | if maps.get(None): 94 | keys.append(None) 95 | 96 | return keys 97 | 98 | async def _get_sorted_event_maps( 99 | self, 100 | ) -> Dict[Optional[int], List[EventAndParameter]]: 101 | """ 102 | event_maps = { 103 | 1: [EventAndParameter], 104 | 2: [EventAndParameter], 105 | None: [EventAndParameter], 106 | } 107 | """ 108 | event_maps: Dict[Optional[int], List[EventAndParameter]] = {None: []} 109 | 110 | for event, parameter in self.events.items(): 111 | if event.ORDER and not isinstance(event.ORDER, int): 112 | raise InvalidOrderTypeException 113 | 114 | info = EventAndParameter(event=event, parameter=parameter) 115 | if not event.ORDER: 116 | event_maps.get(None).append(info) 117 | elif event.ORDER not in event_maps: 118 | event_maps[event.ORDER] = [info] 119 | else: 120 | event_maps.get(event.ORDER).append(info) 121 | 122 | return event_maps 123 | 124 | 125 | class EventHandlerMeta(type): 126 | async def store(self, event: Type[BaseEvent], parameter: BaseModel = None) -> None: 127 | handler = self._get_event_handler() 128 | await handler.store(event=event, parameter=parameter) 129 | 130 | async def _publish(self, run_at_once: bool = True) -> None: 131 | handler = self._get_event_handler() 132 | await handler._publish(run_at_once=run_at_once) 133 | 134 | def _get_event_handler(self) -> EventHandler: 135 | try: 136 | return _handler_context.get() 137 | except LookupError: 138 | raise EmptyContextException 139 | 140 | 141 | class EventHandlerDelegator(metaclass=EventHandlerMeta): 142 | validator = EventHandlerValidator() 143 | 144 | def __init__(self): 145 | self.token = None 146 | 147 | def __enter__(self): 148 | self.token = _handler_context.set(EventHandler(validator=self.validator)) 149 | return type(self) 150 | 151 | def __exit__(self, exc_type, exc_value, traceback): 152 | _handler_context.reset(self.token) 153 | 154 | 155 | event_handler: Type[EventHandlerDelegator] = EventHandlerDelegator 156 | -------------------------------------------------------------------------------- /fastapi_event/listener.py: -------------------------------------------------------------------------------- 1 | from fastapi_event.handler import event_handler 2 | 3 | 4 | class EventListener: 5 | def __init__(self, run_at_once: bool = True): 6 | self.run_at_once = run_at_once 7 | 8 | def __call__(self, func): 9 | async def _inner(*args, **kwargs): 10 | try: 11 | result = await func(*args, **kwargs) 12 | except Exception as e: 13 | raise e from None 14 | 15 | await event_handler._publish(run_at_once=self.run_at_once) 16 | return result 17 | 18 | return _inner 19 | -------------------------------------------------------------------------------- /fastapi_event/middleware.py: -------------------------------------------------------------------------------- 1 | from starlette.types import ASGIApp, Receive, Scope, Send 2 | 3 | from fastapi_event.handler import event_handler 4 | 5 | 6 | class EventHandlerMiddleware: 7 | def __init__(self, app: ASGIApp) -> None: 8 | self.app = app 9 | 10 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 11 | try: 12 | with event_handler(): 13 | await self.app(scope, receive, send) 14 | except Exception as e: 15 | raise e 16 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | rm -rf ./dist/* 3 | python setup.py sdist 4 | twine upload dist/* -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import setuptools 3 | 4 | with open("README.md", "r") as fh: 5 | long_description = fh.read() 6 | 7 | setuptools.setup( 8 | name="fastapi-event", 9 | version="0.1.3", 10 | author="Hide", 11 | author_email="padocon@naver.com", 12 | description="Event dispatcher for FastAPI", 13 | long_description=long_description, 14 | long_description_content_type="text/markdown", 15 | url="https://github.com/teamhide/fastapi-event", 16 | packages=setuptools.find_packages(), 17 | install_requires=[ 18 | "fastapi", 19 | "pydantic", 20 | ], 21 | tests_require=['pytest'], 22 | classifiers=[ 23 | "Intended Audience :: Developers", 24 | "Programming Language :: Python", 25 | "Programming Language :: Python :: 3.7", 26 | "Programming Language :: Python :: 3.8", 27 | "Programming Language :: Python :: 3.9", 28 | "License :: OSI Approved :: Apache Software License", 29 | "Operating System :: OS Independent", 30 | ], 31 | python_requires='>=3.7', 32 | ) -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teamhide/fastapi-event/4e8502e5f1e6744ba7089c359f84e2fb5f5f263d/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import FastAPI 3 | from fastapi.testclient import TestClient 4 | 5 | from fastapi_event import EventHandlerMiddleware 6 | 7 | 8 | @pytest.fixture 9 | def app(): 10 | yield FastAPI() 11 | 12 | 13 | @pytest.fixture 14 | def app_with_middleware(app): 15 | app: FastAPI 16 | app.add_middleware(EventHandlerMiddleware) 17 | yield app 18 | 19 | 20 | @pytest.fixture 21 | def client(app): 22 | with TestClient(app) as client: 23 | yield client 24 | -------------------------------------------------------------------------------- /tests/events.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from fastapi_event import BaseEvent 4 | 5 | 6 | class TestEvent(BaseEvent): 7 | __test__ = False 8 | 9 | async def run(self, parameter=None) -> None: 10 | pass 11 | 12 | 13 | class TestSecondEvent(BaseEvent): 14 | __test__ = False 15 | 16 | async def run(self, parameter=None) -> None: 17 | pass 18 | 19 | 20 | class TestEventDoNotHaveParameter(BaseEvent): 21 | __test__ = False 22 | 23 | async def run(self): 24 | pass 25 | 26 | 27 | class TestEventParameter(BaseModel): 28 | __test__ = False 29 | 30 | content: str 31 | 32 | 33 | class TestEventParameterNotNone(BaseEvent): 34 | __test__ = False 35 | 36 | async def run(self, parameter): 37 | pass 38 | -------------------------------------------------------------------------------- /tests/test_handler.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Type 2 | 3 | import pytest 4 | from pydantic import BaseModel 5 | 6 | from fastapi_event import event_handler, BaseEvent, EventListener 7 | from fastapi_event.exceptions import ( 8 | InvalidEventTypeException, 9 | InvalidParameterTypeException, 10 | ParameterCountException, 11 | RequiredParameterException, 12 | ) 13 | from tests.events import ( 14 | TestSecondEvent, 15 | TestEventParameterNotNone, 16 | TestEvent, 17 | TestEventDoNotHaveParameter, 18 | TestEventParameter, 19 | ) 20 | 21 | 22 | class FirstEvent(BaseEvent): 23 | ORDER = 1 24 | 25 | async def run(self, parameter: Union[Type[BaseModel], None] = None) -> None: 26 | ... 27 | 28 | 29 | class SecondEvent(BaseEvent): 30 | ORDER = 2 31 | 32 | async def run(self, parameter: Union[Type[BaseModel], None] = None) -> None: 33 | ... 34 | 35 | 36 | class NoneOrderEvent(BaseEvent): 37 | async def run(self, parameter: Union[Type[BaseModel], None] = None) -> None: 38 | ... 39 | 40 | 41 | @pytest.mark.asyncio 42 | async def test_store_without_parameter(app_with_middleware, client): 43 | app = app_with_middleware 44 | 45 | async def test(): 46 | await event_handler.store( 47 | event=TestEvent 48 | ) 49 | handler = event_handler._get_event_handler() 50 | assert TestEvent in handler.events 51 | assert handler.events[TestEvent] is None 52 | 53 | @app.get("/") 54 | async def get(): 55 | await test() 56 | 57 | client.get("/") 58 | 59 | 60 | @pytest.mark.asyncio 61 | async def test_multiple_store_without_parameter(app_with_middleware, client): 62 | app = app_with_middleware 63 | 64 | async def test(): 65 | await event_handler.store( 66 | event=TestEvent 67 | ) 68 | await event_handler.store( 69 | event=TestSecondEvent 70 | ) 71 | handler = event_handler._get_event_handler() 72 | assert len(handler.events) == 2 73 | assert TestEvent in handler.events 74 | assert TestSecondEvent in handler.events 75 | assert handler.events[TestEvent] is None 76 | assert handler.events[TestSecondEvent] is None 77 | 78 | @app.get("/") 79 | async def get(): 80 | await test() 81 | 82 | client.get("/") 83 | 84 | 85 | @pytest.mark.asyncio 86 | async def test_store_with_parameter(app_with_middleware, client): 87 | app = app_with_middleware 88 | 89 | async def test(): 90 | await event_handler.store( 91 | event=TestEvent, 92 | parameter=TestEventParameter(content="content"), 93 | ) 94 | handler = event_handler._get_event_handler() 95 | assert len(handler.events) == 1 96 | assert TestEvent in handler.events 97 | assert isinstance(handler.events[TestEvent], TestEventParameter) 98 | 99 | @app.get("/") 100 | async def get(): 101 | await test() 102 | 103 | client.get("/") 104 | 105 | 106 | @pytest.mark.asyncio 107 | async def test_multiple_store_with_parameter(app_with_middleware, client): 108 | app = app_with_middleware 109 | 110 | async def test(): 111 | await event_handler.store( 112 | event=TestEvent, 113 | parameter=TestEventParameter(content="content"), 114 | ) 115 | await event_handler.store( 116 | event=TestSecondEvent, 117 | parameter=TestEventParameter(content="content"), 118 | ) 119 | handler = event_handler._get_event_handler() 120 | assert len(handler.events) == 2 121 | assert TestEvent in handler.events 122 | assert TestSecondEvent in handler.events 123 | assert isinstance(handler.events[TestEvent], TestEventParameter) 124 | assert isinstance(handler.events[TestSecondEvent], TestEventParameter) 125 | 126 | @app.get("/") 127 | async def get(): 128 | await test() 129 | 130 | client.get("/") 131 | 132 | 133 | @pytest.mark.asyncio 134 | async def test_store_with_invalid_event_type_exception(app_with_middleware, client): 135 | app = app_with_middleware 136 | 137 | class InvalidTypeEvent: 138 | pass 139 | 140 | async def test(): 141 | with pytest.raises(InvalidEventTypeException): 142 | await event_handler.store( 143 | event=InvalidTypeEvent 144 | ) 145 | 146 | @app.get("/") 147 | async def get(): 148 | await test() 149 | 150 | client.get("/") 151 | 152 | 153 | @pytest.mark.asyncio 154 | async def test_store_with_invalid_parameter_type_exception(app_with_middleware, client): 155 | app = app_with_middleware 156 | 157 | async def test(): 158 | with pytest.raises(InvalidParameterTypeException): 159 | await event_handler.store( 160 | event=TestEvent, 161 | parameter="a", 162 | ) 163 | 164 | @app.get("/") 165 | async def get(): 166 | await test() 167 | 168 | client.get("/") 169 | 170 | 171 | @pytest.mark.asyncio 172 | async def test_store_with_invalid_parameter_count_exception(app_with_middleware, client): 173 | app = app_with_middleware 174 | 175 | async def test(): 176 | with pytest.raises(ParameterCountException): 177 | await event_handler.store( 178 | event=TestEventDoNotHaveParameter, 179 | ) 180 | 181 | @app.get("/") 182 | async def get(): 183 | await test() 184 | 185 | client.get("/") 186 | 187 | 188 | @pytest.mark.asyncio 189 | async def test_store_with_required_parameter_exception(app_with_middleware, client): 190 | app = app_with_middleware 191 | 192 | async def test(): 193 | with pytest.raises(RequiredParameterException): 194 | await event_handler.store( 195 | event=TestEventParameterNotNone, 196 | ) 197 | 198 | @app.get("/") 199 | async def get(): 200 | await test() 201 | 202 | client.get("/") 203 | 204 | 205 | @pytest.mark.asyncio 206 | async def test_order(app_with_middleware, client): 207 | app = app_with_middleware 208 | 209 | @EventListener() 210 | async def test(): 211 | await event_handler.store(event=FirstEvent) 212 | await event_handler.store(event=SecondEvent) 213 | maps = await event_handler._get_event_handler()._get_sorted_event_maps() 214 | 215 | assert maps.get(None) == [] 216 | 217 | assert len(maps.get(1)) == 1 218 | assert maps.get(1)[0].event == FirstEvent 219 | assert maps.get(1)[0].parameter is None 220 | 221 | assert len(maps.get(2)) == 1 222 | assert maps.get(2)[0].event == SecondEvent 223 | assert maps.get(2)[0].parameter is None 224 | 225 | @app.get("/") 226 | async def test_get(): 227 | await test() 228 | 229 | client.get("/") 230 | 231 | 232 | @pytest.mark.asyncio 233 | async def test_order_if_the_order_of_the_store_is_different(app_with_middleware, client): 234 | app = app_with_middleware 235 | 236 | @EventListener() 237 | async def test(): 238 | await event_handler.store(event=SecondEvent) 239 | await event_handler.store(event=FirstEvent) 240 | maps = await event_handler._get_event_handler()._get_sorted_event_maps() 241 | 242 | assert maps.get(None) == [] 243 | 244 | assert len(maps.get(1)) == 1 245 | assert maps.get(1)[0].event == FirstEvent 246 | assert maps.get(1)[0].parameter is None 247 | 248 | assert len(maps.get(2)) == 1 249 | assert maps.get(2)[0].event == SecondEvent 250 | assert maps.get(2)[0].parameter is None 251 | 252 | @app.get("/") 253 | async def test_get(): 254 | await test() 255 | 256 | client.get("/") 257 | 258 | 259 | @pytest.mark.asyncio 260 | async def test_order_with_none_order(app_with_middleware, client): 261 | app = app_with_middleware 262 | 263 | @EventListener() 264 | async def test(): 265 | await event_handler.store(event=FirstEvent) 266 | await event_handler.store(event=SecondEvent) 267 | await event_handler.store(event=NoneOrderEvent) 268 | maps = await event_handler._get_event_handler()._get_sorted_event_maps() 269 | 270 | assert maps.get(None)[0].event == NoneOrderEvent 271 | assert maps.get(None)[0].parameter is None 272 | 273 | assert len(maps.get(1)) == 1 274 | assert maps.get(1)[0].event == FirstEvent 275 | assert maps.get(1)[0].parameter is None 276 | 277 | assert len(maps.get(2)) == 1 278 | assert maps.get(2)[0].event == SecondEvent 279 | assert maps.get(2)[0].parameter is None 280 | 281 | @app.get("/") 282 | async def test_get(): 283 | await test() 284 | 285 | client.get("/") 286 | 287 | 288 | @pytest.mark.asyncio 289 | async def test_order_with_none_order_if_the_order_of_the_store_is_different(app_with_middleware, client): 290 | app = app_with_middleware 291 | 292 | @EventListener() 293 | async def test(): 294 | await event_handler.store(event=SecondEvent) 295 | await event_handler.store(event=FirstEvent) 296 | await event_handler.store(event=NoneOrderEvent) 297 | maps = await event_handler._get_event_handler()._get_sorted_event_maps() 298 | 299 | assert maps.get(None)[0].event == NoneOrderEvent 300 | assert maps.get(None)[0].parameter is None 301 | 302 | assert len(maps.get(1)) == 1 303 | assert maps.get(1)[0].event == FirstEvent 304 | assert maps.get(1)[0].parameter is None 305 | 306 | assert len(maps.get(2)) == 1 307 | assert maps.get(2)[0].event == SecondEvent 308 | assert maps.get(2)[0].parameter is None 309 | 310 | @app.get("/") 311 | async def test_get(): 312 | await test() 313 | 314 | client.get("/") 315 | -------------------------------------------------------------------------------- /tests/test_listener.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fastapi_event import EventListener, event_handler, BaseEvent 4 | from tests.events import TestEvent, TestEventParameter 5 | 6 | GLOBAL_VAR = 0 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_listener_is_publish_works_well(app_with_middleware, client): 11 | app = app_with_middleware 12 | 13 | @EventListener() 14 | async def test(): 15 | await event_handler.store( 16 | event=TestEvent, 17 | parameter=TestEventParameter(content="content"), 18 | ) 19 | 20 | @app.get("/") 21 | async def test_get(): 22 | assert event_handler._get_event_handler().events == {} 23 | await test() 24 | assert event_handler._get_event_handler().events == {} 25 | 26 | client.get("/") 27 | 28 | 29 | @pytest.mark.asyncio 30 | async def test_listener_is_event_emitted_well(app_with_middleware, client): 31 | app = app_with_middleware 32 | 33 | class TestEventThatChangeGlobalVar(BaseEvent): 34 | async def run(self, parameter=None) -> None: 35 | global GLOBAL_VAR 36 | GLOBAL_VAR = 1 37 | 38 | @EventListener() 39 | async def test(): 40 | await event_handler.store( 41 | event=TestEventThatChangeGlobalVar, 42 | ) 43 | 44 | @app.get("/") 45 | async def test_get(): 46 | global GLOBAL_VAR 47 | assert event_handler._get_event_handler().events == {} 48 | await test() 49 | assert event_handler._get_event_handler().events == {} 50 | assert GLOBAL_VAR == 1 51 | 52 | client.get("/") 53 | 54 | 55 | @pytest.mark.asyncio 56 | async def test_listener_run_at_once(app_with_middleware, client): 57 | app = app_with_middleware 58 | 59 | @EventListener(run_at_once=True) 60 | async def test(): 61 | await event_handler.store( 62 | event=TestEvent, 63 | parameter=TestEventParameter(content="content"), 64 | ) 65 | 66 | @app.get("/") 67 | async def test_get(): 68 | assert event_handler._get_event_handler().events == {} 69 | await test() 70 | assert event_handler._get_event_handler().events == {} 71 | 72 | client.get("/") 73 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from fastapi_event import EventHandlerMiddleware 4 | 5 | 6 | def test_init(app: FastAPI): 7 | app.add_middleware(EventHandlerMiddleware) 8 | 9 | is_exist = False 10 | for middleware in app.user_middleware: 11 | if issubclass(middleware.cls, EventHandlerMiddleware): 12 | is_exist = True 13 | 14 | assert is_exist is True 15 | 16 | 17 | def test_middleware_set_context_properly(app_with_middleware, client): 18 | app = app_with_middleware 19 | 20 | @app.get("/") 21 | async def test_get(): 22 | from fastapi_event.handler import EventHandler, _handler_context # noqa 23 | assert isinstance(_handler_context.get(), EventHandler) 24 | 25 | client.get("/") 26 | 27 | 28 | def test_middleware_does_not_set_context(app, client): 29 | @app.get("/") 30 | async def test_get(): 31 | from fastapi_event.handler import EventHandler, _handler_context # noqa 32 | assert _handler_context.get() is None 33 | 34 | client.get("/") 35 | -------------------------------------------------------------------------------- /tests/test_validator.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fastapi_event.exceptions import ( 4 | InvalidEventTypeException, 5 | InvalidParameterTypeException, 6 | ParameterCountException, 7 | RequiredParameterException, 8 | ) 9 | from fastapi_event.handler import EventHandlerValidator 10 | from tests.events import ( 11 | TestEvent, 12 | TestEventParameter, 13 | TestEventDoNotHaveParameter, 14 | TestEventParameterNotNone, 15 | ) 16 | 17 | 18 | @pytest.mark.asyncio 19 | async def test_validate_with_invalid_event_type_exception(): 20 | class Event: 21 | pass 22 | 23 | with pytest.raises(InvalidEventTypeException): 24 | await EventHandlerValidator().validate( 25 | event=Event, parameter=TestEventParameter(content="content"), 26 | ) 27 | 28 | 29 | @pytest.mark.asyncio 30 | async def test_validate_with_invalid_parameter_type_exception(): 31 | class Parameter: 32 | pass 33 | 34 | with pytest.raises(InvalidParameterTypeException): 35 | await EventHandlerValidator().validate( 36 | event=TestEvent, parameter=Parameter(), 37 | ) 38 | 39 | 40 | @pytest.mark.asyncio 41 | async def test_validate_with_parameter_count_exception(): 42 | with pytest.raises(ParameterCountException): 43 | await EventHandlerValidator().validate( 44 | event=TestEventDoNotHaveParameter, 45 | parameter=TestEventParameter(content="content"), 46 | ) 47 | 48 | 49 | @pytest.mark.asyncio 50 | async def test_validate_with_required_parameter_exception(): 51 | with pytest.raises(RequiredParameterException): 52 | await EventHandlerValidator().validate( 53 | event=TestEventParameterNotNone, 54 | ) 55 | 56 | 57 | @pytest.mark.asyncio 58 | async def test_validate(): 59 | result = await EventHandlerValidator().validate( 60 | event=TestEvent, 61 | parameter=TestEventParameter(content="content") 62 | ) 63 | assert result is None 64 | --------------------------------------------------------------------------------