├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ └── main.yaml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── examples ├── __init__.py ├── custom_default_source │ ├── __init__.py │ ├── example_request.sh │ ├── example_response.txt │ └── example_server.py ├── events_and_basemodels_mixed │ ├── example_requests.sh │ └── example_server.py ├── simple_server │ ├── __init__.py │ ├── example_binary_request.sh │ ├── example_response.txt │ ├── example_server.py │ └── example_structured_request.sh ├── structured_response_server │ ├── __init__.py │ ├── example_request.sh │ ├── example_response.txt │ └── example_server.py └── type_routing │ ├── __init__.py │ ├── example_server.py │ ├── my_example_request.sh │ ├── my_example_response.json │ ├── your_example_request.sh │ └── your_example_response.json ├── fastapi_cloudevents ├── __init__.py ├── cloudevent.py ├── cloudevent_request.py ├── cloudevent_response.py ├── cloudevent_route.py ├── content_type.py ├── installation.py ├── py.typed └── settings.py ├── requirements.txt ├── setup.py ├── tests ├── conftest.py ├── requirements.txt ├── test_cloudevent.py ├── test_cloudevent_request.py ├── test_cloudevent_response.py ├── test_cloudevent_route.py ├── test_content_type.py ├── test_docs.py ├── test_examples │ ├── test_custom_source_tag.py │ ├── test_events_and_basemodels_mixed.py │ ├── test_simple_server.py │ ├── test_structured_response_server.py │ └── test_type_routing.py └── test_installation.py └── tox.ini /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: sasha-tkachev 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Import function from '...' 16 | 2. Call function with args '....' 17 | 3. ... 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **System Details (please complete the following information):** 26 | - Python Version: [e.g. 3.10.0] 27 | - OS: [e.g. Linux] 28 | - FastAPI Version: [e.g. 0.80.0] 29 | 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: sasha-tkachev 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | strategy: 8 | matrix: 9 | python: ["3.7", "3.8", "3.9", "3.10"] 10 | os: [ubuntu-latest, windows-latest, macos-latest] 11 | runs-on: ${{ matrix.os }} 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Setup Python 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: ${{ matrix.python }} 18 | cache: "pip" 19 | cache-dependency-path: "requirements.txt" 20 | - name: Install dev dependencies 21 | run: python -m pip install "tox<4" 22 | - name: Run tests 23 | run: python -m tox -e py # Run tox using the version of Python in `PATH` 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 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 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | .vscode -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to fastapi-cloudevents 2 | 3 | :+1::tada: First off, thanks for taking the time to contribute! :tada::+1: 4 | 5 | We welcome contributions from the community! Please take some time to become 6 | acquainted with the process before submitting a pull request. There are just 7 | a few things to keep in mind. 8 | 9 | ## Pull Requests 10 | 11 | Typically a pull request should relate to an existing issue. If you have 12 | found a bug, want to add an improvement, or suggest an API change, you MAY 13 | create an issue before proceeding with a pull request, but it is not required. 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 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. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.in 2 | include *.ini 3 | include *.rst 4 | include *.txt 5 | include LICENSE 6 | include fastapi_cloudevents/py.typed 7 | 8 | global-exclude __pycache__ *.py[cod] 9 | global-exclude *.so *.dylib -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastapi-cloudevents 2 | 3 | [![](https://github.com/sasha-tkachev/fastapi-cloudevents/actions/workflows/main.yaml/badge.svg)](https://github.com/sasha-tkachev/fastapi-cloudevents/actions/workflows/main.yaml) 4 | [![](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/sasha-tkachev/fastapi-cloudevents/blob/main/tests/test_docs.py#L35) 5 | 6 | [FastAPI](https://fastapi.tiangolo.com/) plugin for [CloudEvents](https://cloudevents.io/) Integration 7 | 8 | Allows to easily consume and produce CloudEvents over REST API. 9 | 10 | Automatically parses CloudEvents both in the binary and structured format and 11 | provides an interface very similar to the regular FastAPI interface. No more 12 | hustling with `to_structured` and `from_http` function calls! 13 | 14 | ```python 15 | @app.post("/") 16 | async def on_event(event: CloudEvent) -> CloudEvent: 17 | pass 18 | ``` 19 | 20 | See more examples below 21 | 22 | ### Install 23 | 24 | ```shell script 25 | pip install fastapi-cloudevents 26 | ``` 27 | 28 | ## Examples 29 | 30 | ### [Simple Example](examples/simple_server) 31 | 32 | ```python 33 | import uvicorn 34 | from fastapi import FastAPI 35 | 36 | from fastapi_cloudevents import CloudEvent, install_fastapi_cloudevents 37 | 38 | app = FastAPI() 39 | app = install_fastapi_cloudevents(app) 40 | 41 | 42 | @app.post("/") 43 | async def on_event(event: CloudEvent) -> CloudEvent: 44 | return CloudEvent( 45 | type="my.response-type.v1", 46 | data=event.data, 47 | datacontenttype=event.datacontenttype, 48 | ) 49 | 50 | 51 | if __name__ == "__main__": 52 | uvicorn.run(app, host="0.0.0.0", port=8000) 53 | ``` 54 | 55 | The rout accepts both binary CloudEvents 56 | 57 | ```shell script 58 | curl http://localhost:8000 -i -X POST -d "Hello World!" \ 59 | -H "Content-Type: text/plain" \ 60 | -H "ce-specversion: 1.0" \ 61 | -H "ce-type: my.request-type.v1" \ 62 | -H "ce-id: 123" \ 63 | -H "ce-source: my-source" 64 | ``` 65 | 66 | And structured CloudEvents 67 | 68 | ```shell script 69 | curl http://localhost:8000 -i -X POST -H "Content-Type: application/json" \ 70 | -d '{"data":"Hello World", "source":"my-source", "id":"123", "type":"my.request-type.v1","specversion":"1.0"}' 71 | ``` 72 | 73 | Both of the requests will yield a response in the same format: 74 | 75 | ```text 76 | HTTP/1.1 200 OK 77 | date: Fri, 05 Aug 2022 23:50:52 GMT 78 | server: uvicorn 79 | content-length: 13 80 | content-type: application/json 81 | ce-specversion: 1.0 82 | ce-id: 25cd28f0-0605-4a76-b1d8-cffbe3375413 83 | ce-source: http://localhost:8000/ 84 | ce-type: my.response-type.v1 85 | ce-time: 2022-08-05T23:50:52.809697+00:00 86 | 87 | "Hello World" 88 | ``` 89 | 90 | ### [CloudEvent Type Routing](examples/type_routing) 91 | 92 | ```python 93 | from typing import Literal, Union 94 | 95 | import uvicorn 96 | from fastapi import FastAPI, Body 97 | from pydantic import Field 98 | from typing_extensions import Annotated 99 | 100 | from fastapi_cloudevents import ( 101 | CloudEvent, 102 | CloudEventSettings, 103 | ContentMode, 104 | install_fastapi_cloudevents, 105 | ) 106 | 107 | app = FastAPI() 108 | app = install_fastapi_cloudevents( 109 | app, settings=CloudEventSettings(default_response_mode=ContentMode.structured) 110 | ) 111 | 112 | 113 | class MyEvent(CloudEvent): 114 | type: Literal["my.type.v1"] 115 | 116 | 117 | class YourEvent(CloudEvent): 118 | type: Literal["your.type.v1"] 119 | 120 | 121 | OurEvent = Annotated[Union[MyEvent, YourEvent], Body(discriminator="type")] 122 | 123 | _source = "dummy:source" 124 | 125 | 126 | @app.post("/") 127 | async def on_event(event: OurEvent) -> CloudEvent: 128 | if isinstance(event, MyEvent): 129 | return CloudEvent( 130 | type="my.response-type.v1", 131 | data=f"got {event.data} from my event!", 132 | datacontenttype="text/plain", 133 | ) 134 | else: 135 | return CloudEvent( 136 | type="your.response-type.v1", 137 | data=f"got {event.data} from your event!", 138 | datacontenttype="text/plain", 139 | ) 140 | 141 | 142 | if __name__ == "__main__": 143 | uvicorn.run(app, host="0.0.0.0", port=8002) 144 | ``` 145 | 146 | ### [Structured Response Example](examples/structured_response_server) 147 | 148 | To send the response in the http CloudEvent structured format, you MAY use the 149 | `BinaryCloudEventResponse` class 150 | 151 | ```python 152 | import uvicorn 153 | from fastapi import FastAPI 154 | 155 | from fastapi_cloudevents import (CloudEvent, StructuredCloudEventResponse, 156 | install_fastapi_cloudevents) 157 | 158 | app = FastAPI() 159 | app = install_fastapi_cloudevents(app) 160 | 161 | 162 | @app.post("/", response_class=StructuredCloudEventResponse) 163 | async def on_event(event: CloudEvent) -> CloudEvent: 164 | return CloudEvent( 165 | type="com.my-corp.response.v1", 166 | data=event.data, 167 | datacontenttype=event.datacontenttype, 168 | ) 169 | 170 | 171 | if __name__ == "__main__": 172 | uvicorn.run(app, host="0.0.0.0", port=8001) 173 | 174 | ``` 175 | 176 | ```shell script 177 | curl http://localhost:8001 -i -X POST -d "Hello World!" \ 178 | -H "Content-Type: text/plain" \ 179 | -H "ce-specversion: 1.0" \ 180 | -H "ce-type: my.request-type.v1" \ 181 | -H "ce-id: 123" \ 182 | -H "ce-source: my-source" 183 | ``` 184 | 185 | ```text 186 | HTTP/1.1 200 OK 187 | date: Fri, 05 Aug 2022 23:51:26 GMT 188 | server: uvicorn 189 | content-length: 247 190 | content-type: application/json 191 | 192 | {"data":"Hello World!","source":"http://localhost:8001/","id":"3412321f-85b3-4f7f-a551-f4c23a05de3a","type":"com.my-corp.response.v1","specversion":"1.0","time":"2022-08-05T23:51:26.878723+00:00","datacontenttype":"text/plain"} 193 | ``` 194 | 195 | ## More Examples 196 | 197 | - [Custom Default Source](examples/custom_default_source) 198 | - [Mixed Usage of events and regular models](examples/events_and_basemodels_mixed) 199 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasha-tkachev/fastapi-cloudevents/7c269b450804859ce468931b086ff70f513fbb37/examples/__init__.py -------------------------------------------------------------------------------- /examples/custom_default_source/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasha-tkachev/fastapi-cloudevents/7c269b450804859ce468931b086ff70f513fbb37/examples/custom_default_source/__init__.py -------------------------------------------------------------------------------- /examples/custom_default_source/example_request.sh: -------------------------------------------------------------------------------- 1 | curl http://localhost:8003 -i -------------------------------------------------------------------------------- /examples/custom_default_source/example_response.txt: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | date: Sat, 06 Aug 2022 01:33:30 GMT 3 | server: uvicorn 4 | content-type: application/json 5 | ce-specversion: 1.0 6 | ce-id: d6a570df-a68f-4d7f-b0d3-944de01b0214 7 | ce-source: my-source 8 | ce-type: my.event.v1 9 | ce-time: 2022-08-06T01:33:31.090707+00:00 10 | transfer-encoding: chunked 11 | 12 | -------------------------------------------------------------------------------- /examples/custom_default_source/example_server.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import uvicorn 4 | from fastapi import FastAPI 5 | 6 | from fastapi_cloudevents import CloudEvent, install_fastapi_cloudevents 7 | from fastapi_cloudevents.settings import CloudEventSettings 8 | 9 | app = FastAPI() 10 | app = install_fastapi_cloudevents( 11 | app, settings=CloudEventSettings(default_source="my-source") 12 | ) 13 | 14 | 15 | @app.get("/") 16 | async def index() -> CloudEvent: 17 | i = random.randint(0, 3) 18 | if i == 0: 19 | # will have "my-source" as the source 20 | return CloudEvent(type="my.event.v1") 21 | if i == 1: 22 | # will have "my-source" as the source 23 | return CloudEvent(type="my.other-event.v1") 24 | else: 25 | # will have "his-source" as the source 26 | return CloudEvent(type="his.event.v1", source="his-source") 27 | 28 | 29 | if __name__ == "__main__": 30 | uvicorn.run(app, host="0.0.0.0", port=8003) 31 | -------------------------------------------------------------------------------- /examples/events_and_basemodels_mixed/example_requests.sh: -------------------------------------------------------------------------------- 1 | curl http://localhost:8004/event-response -i -X POST -d '{"my_value": "Hello World!"}' 2 | curl http://localhost:8004/model-response -i -X POST -d '{"my_value": "Hello World!"}' 3 | -------------------------------------------------------------------------------- /examples/events_and_basemodels_mixed/example_server.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from fastapi import FastAPI 3 | from pydantic.main import BaseModel 4 | 5 | from fastapi_cloudevents import CloudEvent, install_fastapi_cloudevents 6 | 7 | app = FastAPI() 8 | app = install_fastapi_cloudevents(app) 9 | 10 | 11 | class MyModel(BaseModel): 12 | my_value: str 13 | 14 | 15 | @app.post("/event-response") 16 | async def on_event(value: MyModel) -> CloudEvent: 17 | return CloudEvent( 18 | type="my.model-acknowledged.v1", 19 | data=value.model_dump(), 20 | ) 21 | 22 | 23 | @app.post("/model-response") 24 | async def on_event(value: MyModel) -> MyModel: 25 | return value.model_dump() 26 | 27 | 28 | if __name__ == "__main__": 29 | uvicorn.run(app, host="0.0.0.0", port=8004) 30 | -------------------------------------------------------------------------------- /examples/simple_server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasha-tkachev/fastapi-cloudevents/7c269b450804859ce468931b086ff70f513fbb37/examples/simple_server/__init__.py -------------------------------------------------------------------------------- /examples/simple_server/example_binary_request.sh: -------------------------------------------------------------------------------- 1 | curl http://localhost:8000 -i -X POST -d "Hello World!" \ 2 | -H "Content-Type: text/plain" \ 3 | -H "ce-specversion: 1.0" \ 4 | -H "ce-type: my.request-type.v1" \ 5 | -H "ce-id: 123" \ 6 | -H "ce-source: my-source" 7 | -------------------------------------------------------------------------------- /examples/simple_server/example_response.txt: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | date: Fri, 05 Aug 2022 23:50:52 GMT 3 | server: uvicorn 4 | content-length: 13 5 | content-type: application/json 6 | ce-specversion: 1.0 7 | ce-id: 25cd28f0-0605-4a76-b1d8-cffbe3375413 8 | ce-source: http://localhost:8000/ 9 | ce-type: my.response-type.v1 10 | ce-time: 2022-08-05T23:50:52.809697+00:00 11 | 12 | "Hello World" -------------------------------------------------------------------------------- /examples/simple_server/example_server.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from fastapi import FastAPI 3 | 4 | from fastapi_cloudevents import CloudEvent, install_fastapi_cloudevents 5 | 6 | app = FastAPI() 7 | app = install_fastapi_cloudevents(app) 8 | 9 | 10 | @app.post("/") 11 | async def on_event(event: CloudEvent) -> CloudEvent: 12 | return CloudEvent( 13 | type="my.response-type.v1", 14 | data=event.data, 15 | datacontenttype=event.datacontenttype, 16 | ) 17 | 18 | 19 | if __name__ == "__main__": 20 | uvicorn.run(app, host="0.0.0.0", port=8000) 21 | -------------------------------------------------------------------------------- /examples/simple_server/example_structured_request.sh: -------------------------------------------------------------------------------- 1 | curl http://localhost:8000 -i -X POST -H "Content-Type: application/json" \ 2 | -d '{"data":"Hello World", "source":"my-source", "id":"123", "type":"my.request-type.v1","specversion":"1.0"}' 3 | -------------------------------------------------------------------------------- /examples/structured_response_server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasha-tkachev/fastapi-cloudevents/7c269b450804859ce468931b086ff70f513fbb37/examples/structured_response_server/__init__.py -------------------------------------------------------------------------------- /examples/structured_response_server/example_request.sh: -------------------------------------------------------------------------------- 1 | curl http://localhost:8001 -i -X POST -d "Hello World!" \ 2 | -H "Content-Type: text/plain" \ 3 | -H "ce-specversion: 1.0" \ 4 | -H "ce-type: my.request-type.v1" \ 5 | -H "ce-id: 123" \ 6 | -H "ce-source: my-source" 7 | -------------------------------------------------------------------------------- /examples/structured_response_server/example_response.txt: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | date: Fri, 05 Aug 2022 23:51:26 GMT 3 | server: uvicorn 4 | content-length: 247 5 | content-type: application/json 6 | 7 | {"data":"Hello World!","source":"http://localhost:8001/","id":"3412321f-85b3-4f7f-a551-f4c23a05de3a","type":"com.my-corp.response.v1","specversion":"1.0","time":"2022-08-05T23:51:26.878723+00:00","datacontenttype":"text/plain"} -------------------------------------------------------------------------------- /examples/structured_response_server/example_server.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from fastapi import FastAPI 3 | 4 | from fastapi_cloudevents import (CloudEvent, StructuredCloudEventResponse, 5 | install_fastapi_cloudevents) 6 | 7 | app = FastAPI() 8 | app = install_fastapi_cloudevents(app) 9 | 10 | 11 | @app.post("/", response_class=StructuredCloudEventResponse) 12 | async def on_event(event: CloudEvent) -> CloudEvent: 13 | return CloudEvent( 14 | type="com.my-corp.response.v1", 15 | data=event.data, 16 | datacontenttype=event.datacontenttype, 17 | ) 18 | 19 | 20 | if __name__ == "__main__": 21 | uvicorn.run(app, host="0.0.0.0", port=8001) 22 | -------------------------------------------------------------------------------- /examples/type_routing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasha-tkachev/fastapi-cloudevents/7c269b450804859ce468931b086ff70f513fbb37/examples/type_routing/__init__.py -------------------------------------------------------------------------------- /examples/type_routing/example_server.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Union 2 | 3 | import uvicorn 4 | from fastapi import FastAPI, Body 5 | from pydantic import Field 6 | from typing_extensions import Annotated 7 | 8 | from fastapi_cloudevents import ( 9 | CloudEvent, 10 | CloudEventSettings, 11 | ContentMode, 12 | install_fastapi_cloudevents, 13 | ) 14 | 15 | app = FastAPI() 16 | app = install_fastapi_cloudevents( 17 | app, settings=CloudEventSettings(default_response_mode=ContentMode.structured) 18 | ) 19 | 20 | 21 | class MyEvent(CloudEvent): 22 | type: Literal["my.type.v1"] 23 | 24 | 25 | class YourEvent(CloudEvent): 26 | type: Literal["your.type.v1"] 27 | 28 | 29 | OurEvent = Annotated[Union[MyEvent, YourEvent], Body(discriminator="type")] 30 | 31 | _source = "dummy:source" 32 | 33 | 34 | @app.post("/") 35 | async def on_event(event: OurEvent) -> CloudEvent: 36 | if isinstance(event, MyEvent): 37 | return CloudEvent( 38 | type="my.response-type.v1", 39 | data=f"got {event.data} from my event!", 40 | datacontenttype="text/plain", 41 | ) 42 | else: 43 | return CloudEvent( 44 | type="your.response-type.v1", 45 | data=f"got {event.data} from your event!", 46 | datacontenttype="text/plain", 47 | ) 48 | 49 | 50 | if __name__ == "__main__": 51 | uvicorn.run(app, host="0.0.0.0", port=8002) 52 | -------------------------------------------------------------------------------- /examples/type_routing/my_example_request.sh: -------------------------------------------------------------------------------- 1 | curl http://localhost:8002 -X POST -d "Hello World!" \ 2 | -H "Content-Type: text/plain" \ 3 | -H "ce-specversion: 1.0" \ 4 | -H "ce-type: my.type.v1" \ 5 | -H "ce-id: 123" \ 6 | -H "ce-source: my-source" -------------------------------------------------------------------------------- /examples/type_routing/my_example_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": "got b'Hello World!' from my event!", 3 | "source": "http://localhost:8002/", 4 | "id": "54eedfab-47f2-4778-be97-ec27bbf4edff", 5 | "type": "my.response-type.v1", 6 | "specversion": "1.0", 7 | "time": "2022-08-05T23:24:41.368148+00:00", 8 | "datacontenttype": "text/plain" 9 | } 10 | -------------------------------------------------------------------------------- /examples/type_routing/your_example_request.sh: -------------------------------------------------------------------------------- 1 | curl http://localhost:8002 -X POST -d "Hello World!" \ 2 | -H "Content-Type: text/plain" \ 3 | -H "ce-specversion: 1.0" \ 4 | -H "ce-type: your.type.v1" \ 5 | -H "ce-id: 123" \ 6 | -H "ce-source: your-source" -------------------------------------------------------------------------------- /examples/type_routing/your_example_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": "got b'Hello World!' from your event!", 3 | "source": "dummy:source", 4 | "id": "b462731b-1273-4f08-9471-490a6d18625a", 5 | "type": "your.response-type.v1", 6 | "specversion": "1.0", 7 | "time": "2022-08-05T23:25:39.379536+00:00", 8 | "datacontenttype": "text/plain" 9 | } 10 | -------------------------------------------------------------------------------- /fastapi_cloudevents/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi_cloudevents.cloudevent import CloudEvent 2 | from fastapi_cloudevents.cloudevent_request import CloudEventRequest 3 | from fastapi_cloudevents.cloudevent_response import ( 4 | BinaryCloudEventResponse, 5 | StructuredCloudEventResponse, 6 | ) 7 | from fastapi_cloudevents.installation import install_fastapi_cloudevents 8 | from fastapi_cloudevents.settings import CloudEventSettings, ContentMode 9 | -------------------------------------------------------------------------------- /fastapi_cloudevents/cloudevent.py: -------------------------------------------------------------------------------- 1 | import cloudevents.pydantic 2 | 3 | DEFAULT_SOURCE = "fastapi" 4 | DEFAULT_SOURCE_ENCODED = DEFAULT_SOURCE.encode("utf-8") 5 | 6 | 7 | class CloudEvent(cloudevents.pydantic.CloudEvent): 8 | """ 9 | Same as the official pydantic CloudEvent model, but if no source is given, 10 | The source will be injected via the CloudEvent response class. 11 | """ 12 | 13 | source: str = DEFAULT_SOURCE 14 | -------------------------------------------------------------------------------- /fastapi_cloudevents/cloudevent_request.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, Type 3 | 4 | from cloudevents.conversion import to_dict 5 | from cloudevents.exceptions import MissingRequiredFields 6 | from cloudevents.http import CloudEvent, from_http 7 | from starlette.requests import Request 8 | 9 | from fastapi_cloudevents.content_type import is_json_content_type_event 10 | from fastapi_cloudevents.settings import CloudEventSettings 11 | 12 | 13 | def _should_fix_json_data_payload(event: CloudEvent) -> bool: 14 | if isinstance(event.data, (str, bytes)): 15 | return is_json_content_type_event(event) 16 | else: 17 | return False # not encoded json payload 18 | 19 | 20 | def _best_effort_fix_json_data_payload(event: CloudEvent) -> CloudEvent: 21 | try: 22 | if _should_fix_json_data_payload(event): 23 | event.data = json.loads( 24 | event.data, # type: ignore # MUST be str or bytes 25 | ) 26 | except (json.JSONDecodeError, TypeError, UnicodeDecodeError): 27 | pass 28 | return event 29 | 30 | 31 | class CloudEventRequest(Request): 32 | _settings: CloudEventSettings = CloudEventSettings() 33 | _json: Any 34 | _body: Any 35 | 36 | async def body(self) -> bytes: 37 | if not hasattr(self, "_body"): 38 | body = await super().body() 39 | try: 40 | event = _best_effort_fix_json_data_payload( 41 | from_http(dict(self.headers), body) 42 | ) 43 | self._json = to_dict(event) 44 | # avoid fastapi>=0.66 content type check 45 | # https://github.com/sasha-tkachev/fastapi-cloudevents/issues/5 46 | self._body = self._json 47 | except MissingRequiredFields: 48 | if self._settings.allow_non_cloudevent_models: 49 | # This is not a CloudEvent, maybe some other model, will let FastAPI 50 | # decide down-stream 51 | self._body = body 52 | else: 53 | raise 54 | return self._body 55 | 56 | @classmethod 57 | def configured(cls, settings: CloudEventSettings) -> Type["CloudEventRequest"]: 58 | class ConfiguredCloudEventRequest(cls): # type: ignore # it is valid 59 | _settings = settings 60 | 61 | return ConfiguredCloudEventRequest 62 | -------------------------------------------------------------------------------- /fastapi_cloudevents/cloudevent_response.py: -------------------------------------------------------------------------------- 1 | import json 2 | import typing 3 | from abc import abstractmethod 4 | from typing import Any, AnyStr, Dict, List, Type, Tuple 5 | 6 | from cloudevents.abstract import CloudEvent 7 | from cloudevents.conversion import to_binary 8 | from cloudevents.exceptions import MissingRequiredFields 9 | from cloudevents.http import from_dict 10 | from starlette.background import BackgroundTask 11 | from starlette.responses import JSONResponse 12 | 13 | from fastapi_cloudevents.settings import CloudEventSettings 14 | from fastapi_cloudevents.cloudevent import DEFAULT_SOURCE, DEFAULT_SOURCE_ENCODED 15 | from fastapi_cloudevents.content_type import is_json_content_type_event 16 | 17 | 18 | class _CloudEventResponse: 19 | @abstractmethod 20 | def replace_default_source(self, new_source: str): 21 | pass # pragma: no cover 22 | 23 | 24 | RawHeaders = List[Tuple[bytes, Any]] 25 | 26 | 27 | def _encoded_string(s: AnyStr) -> bytes: 28 | if isinstance(s, bytes): 29 | return s 30 | return s.encode("utf-8") 31 | 32 | 33 | def _update_headers( 34 | headers: RawHeaders, new_headers: Dict[AnyStr, AnyStr] 35 | ) -> RawHeaders: 36 | result = dict(headers) 37 | result.update( 38 | {_encoded_string(k).lower(): _encoded_string(v) for k, v in new_headers.items()} 39 | ) 40 | return list(result.items()) 41 | 42 | 43 | class StructuredCloudEventResponse(JSONResponse, _CloudEventResponse): 44 | _settings: CloudEventSettings = CloudEventSettings() 45 | 46 | # starlette response does not init it in __init__ directly, so we need to hint it 47 | raw_headers: RawHeaders 48 | 49 | # https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/formats/json-format.md#3-envelope 50 | media_type = "application/cloudevents+json" 51 | 52 | def replace_default_source(self, new_source: str): 53 | result = json.loads(self.body) 54 | if result.get("source") == DEFAULT_SOURCE: 55 | result["source"] = new_source 56 | self._re_render(result) 57 | 58 | def _re_render(self, content: typing.Any) -> None: 59 | self.body = self.render(content) 60 | content_length = str(len(self.body)) 61 | self.raw_headers = _update_headers( 62 | self.raw_headers, {b"content-length": content_length.encode("latin-1")} 63 | ) 64 | 65 | @classmethod 66 | def configured(cls, settings: CloudEventSettings) -> Type["_CloudEventResponse"]: 67 | class ConfiguredStructuredCloudEventResponse(cls): # type: ignore # it is valid 68 | _settings = settings 69 | 70 | return ConfiguredStructuredCloudEventResponse 71 | 72 | 73 | _CE_SOURCE_HEADER_NAME = b"ce-source" 74 | 75 | 76 | def _empty_body_value(event: CloudEvent): 77 | """ 78 | We MUST return a non-None http payload to the client, but the to_binary 79 | function returned None. 80 | The sensible thing to do is to return b"" an empty buffer. 81 | The problem is that if the datacontenttype of the event 82 | is `application/json` (which it is by default for CloudEvents) b"" is an invalid 83 | json buffer, and trying to parse it on the client will result in an error. So to 84 | handle this case We return b"null" so when the client 85 | parses the body he will get a `None` 86 | """ 87 | if is_json_content_type_event(event): 88 | return b"null" # empty buffer is not a valid json value 89 | else: 90 | return b"" 91 | 92 | 93 | class BinaryCloudEventResponse(JSONResponse, _CloudEventResponse): 94 | _settings: CloudEventSettings = CloudEventSettings() 95 | 96 | def __init__( 97 | self, 98 | content: Dict[AnyStr, Any], 99 | status_code: int = 200, 100 | headers: dict = None, # type: ignore # same as JSONResponse 101 | media_type: str = None, # type: ignore # same as JSONResponse 102 | background: BackgroundTask = None, # type: ignore # same as JSONResponse 103 | ) -> None: 104 | super(BinaryCloudEventResponse, self).__init__( 105 | content=content, 106 | status_code=status_code, 107 | headers=headers, 108 | media_type="application/json" if media_type is None else media_type, 109 | # the default content type is json, but may be overridden by the event 110 | # datacontenttype attribute 111 | background=background, 112 | ) 113 | self.raw_headers = self._render_headers(content, headers=self.raw_headers) 114 | 115 | def render(self, content: Dict[AnyStr, Any]) -> bytes: 116 | try: 117 | event = from_dict( 118 | content, # type: ignore 119 | ) 120 | _, body = to_binary(event) 121 | if body is None: 122 | return _empty_body_value(event) 123 | return body 124 | except MissingRequiredFields: 125 | if self._settings.allow_non_cloudevent_models: 126 | return super(BinaryCloudEventResponse, self).render(content) 127 | else: 128 | raise 129 | 130 | @classmethod 131 | def _render_headers(cls, content: Dict[AnyStr, Any], headers: RawHeaders): 132 | try: 133 | ce_headers, _ = to_binary( 134 | from_dict( 135 | content, # type: ignore 136 | ) 137 | ) 138 | headers = _update_headers(headers, ce_headers) 139 | except MissingRequiredFields: 140 | if not cls._settings.allow_non_cloudevent_models: 141 | raise 142 | 143 | return headers 144 | 145 | def replace_default_source(self, new_source: str): 146 | if (_CE_SOURCE_HEADER_NAME, DEFAULT_SOURCE_ENCODED) in self.raw_headers: 147 | self.raw_headers = _update_headers( 148 | self.raw_headers, {_CE_SOURCE_HEADER_NAME: new_source.encode("utf-8")} 149 | ) 150 | 151 | @classmethod 152 | def configured( 153 | cls, settings: CloudEventSettings 154 | ) -> Type["BinaryCloudEventResponse"]: 155 | class ConfiguredBinaryCloudEventResponse(cls): # type: ignore # it is valid 156 | _settings = settings 157 | 158 | return ConfiguredBinaryCloudEventResponse 159 | -------------------------------------------------------------------------------- /fastapi_cloudevents/cloudevent_route.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Type 2 | 3 | from fastapi.routing import APIRoute 4 | from starlette.requests import Request 5 | from starlette.responses import Response 6 | 7 | from fastapi_cloudevents.cloudevent_request import CloudEventRequest 8 | from fastapi_cloudevents.cloudevent_response import _CloudEventResponse 9 | from fastapi_cloudevents.settings import CloudEventSettings 10 | 11 | 12 | def _route_source(request: Request, settings: CloudEventSettings): 13 | if settings.default_source: 14 | return settings.default_source 15 | return str(request.url) 16 | 17 | 18 | class CloudEventRoute(APIRoute): 19 | _settings: CloudEventSettings = CloudEventSettings() 20 | _request_class: Type[CloudEventRequest] = CloudEventRequest 21 | 22 | def get_route_handler(self) -> Callable: 23 | original_route_handler = super().get_route_handler() 24 | 25 | async def custom_route_handler(request: Request) -> Response: 26 | response = await original_route_handler( 27 | self._request_class(request.scope, request.receive) 28 | ) 29 | if isinstance(response, _CloudEventResponse): 30 | response.replace_default_source( 31 | new_source=_route_source(request, self._settings) 32 | ) 33 | return response 34 | 35 | return custom_route_handler 36 | 37 | @classmethod 38 | def configured(cls, settings: CloudEventSettings) -> Type["CloudEventRoute"]: 39 | class ConfiguredCloudEventRoute(cls): # type: ignore # it is valid 40 | _settings: CloudEventSettings = settings 41 | _request_class = CloudEventRequest.configured(settings) 42 | 43 | return ConfiguredCloudEventRoute 44 | -------------------------------------------------------------------------------- /fastapi_cloudevents/content_type.py: -------------------------------------------------------------------------------- 1 | import re 2 | import typing 3 | 4 | # https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/formats/json-format.md#311-payload-serialization 5 | from cloudevents.abstract import AnyCloudEvent 6 | 7 | _JSON_CONTENT_TYPE_PATTERN = re.compile( 8 | r"^(.*/.*json|.*/.*\+json)$", flags=re.IGNORECASE 9 | ) 10 | 11 | 12 | def _is_json_content_type(data_content_type: typing.Optional[str]) -> bool: 13 | """ 14 | Assuming asking about the datacontenttype attribute value 15 | """ 16 | if data_content_type is None: 17 | # according to spec: an event with no datacontenttype is exactly equivalent to 18 | # one with datacontenttype="application/json". 19 | return True 20 | return bool(_JSON_CONTENT_TYPE_PATTERN.match(data_content_type)) 21 | 22 | 23 | def is_json_content_type_event(event: AnyCloudEvent) -> bool: 24 | return _is_json_content_type(event.get("datacontenttype")) 25 | -------------------------------------------------------------------------------- /fastapi_cloudevents/installation.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from typing import Optional, Type 3 | 4 | from fastapi import FastAPI 5 | from starlette.responses import JSONResponse 6 | 7 | from fastapi_cloudevents import BinaryCloudEventResponse, StructuredCloudEventResponse 8 | from fastapi_cloudevents.cloudevent_response import _CloudEventResponse 9 | from fastapi_cloudevents.cloudevent_route import CloudEventRoute 10 | from fastapi_cloudevents.settings import CloudEventSettings, ContentMode 11 | 12 | logger = getLogger(__name__) 13 | 14 | 15 | def _choose_default_response_class( 16 | settings: CloudEventSettings, 17 | ) -> Type[_CloudEventResponse]: 18 | if settings.default_response_mode == ContentMode.binary: 19 | return BinaryCloudEventResponse.configured(settings) 20 | if settings.default_response_mode == ContentMode.structured: 21 | return StructuredCloudEventResponse.configured(settings) 22 | raise ValueError("Unknown response mode {}".format(settings.default_response_mode)) 23 | 24 | 25 | def install_fastapi_cloudevents( 26 | app: FastAPI, 27 | settings: Optional[CloudEventSettings] = None, 28 | ) -> FastAPI: 29 | if settings is None: 30 | settings = CloudEventSettings() 31 | if app.router.default_response_class != JSONResponse: 32 | logger.warning("overriding custom non json response default response class") 33 | default_response_class = _choose_default_response_class(settings) 34 | app.router.default_response_class = default_response_class # type: ignore 35 | 36 | app.router.route_class = CloudEventRoute.configured(settings) 37 | return app 38 | -------------------------------------------------------------------------------- /fastapi_cloudevents/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasha-tkachev/fastapi-cloudevents/7c269b450804859ce468931b086ff70f513fbb37/fastapi_cloudevents/py.typed -------------------------------------------------------------------------------- /fastapi_cloudevents/settings.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Optional 3 | 4 | from pydantic import StringConstraints, Field 5 | 6 | from typing_extensions import Annotated 7 | from pydantic_settings import BaseSettings 8 | 9 | 10 | class ContentMode(Enum): 11 | binary = "binary" 12 | structured = "structured" 13 | 14 | 15 | class CloudEventSettings(BaseSettings): 16 | default_source: Optional[Annotated[str, StringConstraints(min_length=1)]] = None 17 | default_response_mode: ContentMode = ContentMode.binary 18 | allow_non_cloudevent_models: bool = Field( 19 | default=True, 20 | description="When allowed, will not fail on non-CloudEvent objects", 21 | ) 22 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi>=0.62.0 2 | cloudevents[pydantic] 3 | pydantic_settings -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import setuptools 4 | 5 | 6 | def _read_requirements(filename): 7 | return [ 8 | line.strip() 9 | for line in Path(filename).read_text().splitlines() 10 | if not line.startswith("#") 11 | ] 12 | 13 | 14 | here = Path(__file__).parent.resolve() 15 | long_description = (here / "README.md").read_text(encoding="utf-8") 16 | 17 | if __name__ == "__main__": 18 | setuptools.setup( 19 | name="fastapi-cloudevents", 20 | version="2.0.2", 21 | author="Alexander Tkachev", 22 | author_email="sasha64sasha@gmail.com", 23 | description="FastAPI plugin for CloudEvents Integration", 24 | long_description_content_type="text/markdown", 25 | long_description=long_description, 26 | home_page="https://github.com/sasha-tkachev/fastapi-cloudevents", 27 | url="https://github.com/sasha-tkachev/fastapi-cloudevents", 28 | keywords=[ 29 | "fastapi", 30 | "cloudevents[pydantic]", 31 | "ce", 32 | "cloud", 33 | "events", 34 | "event", 35 | "middleware", 36 | "rest", 37 | "rest-api", 38 | "plugin", 39 | "pydantic", 40 | "fastapi-extension", 41 | ], 42 | packages=setuptools.find_packages(), 43 | package_data={"fastapi_cloudevents": ["py.typed"]}, 44 | install_requires=_read_requirements("requirements.txt"), 45 | classifiers=[ 46 | "Intended Audience :: Information Technology", 47 | "Intended Audience :: System Administrators", 48 | "Intended Audience :: Developers", 49 | "License :: OSI Approved :: Apache Software License", 50 | "Development Status :: 5 - Production/Stable", 51 | "Operating System :: OS Independent", 52 | "Programming Language :: Python :: 3", 53 | "Programming Language :: Python :: 3.7", 54 | "Programming Language :: Python :: 3.8", 55 | "Programming Language :: Python :: 3.9", 56 | "Programming Language :: Python :: 3.10", 57 | ], 58 | ) 59 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | 4 | import pytest 5 | 6 | 7 | @pytest.fixture(scope="session", autouse=True) 8 | def fix_set_wakeup_fd_issue(): 9 | """ 10 | see https://stackoverflow.com/questions/60359157/valueerror-set-wakeup-fd-only-works 11 | -in-main-thread-on-windows-on-python-3-8-wit 12 | """ 13 | if sys.platform == "win32" and sys.version_info >= (3, 8, 0): 14 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 15 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | flake8 2 | pep8-naming 3 | flake8-import-order 4 | flake8-print 5 | flake8-strict 6 | pytest 7 | pytest-cov 8 | requests 9 | uvicorn 10 | pytest-asyncio 11 | httpx 12 | -------------------------------------------------------------------------------- /tests/test_cloudevent.py: -------------------------------------------------------------------------------- 1 | from fastapi_cloudevents import CloudEvent 2 | from fastapi_cloudevents.cloudevent import DEFAULT_SOURCE 3 | 4 | 5 | def test_fastapi_cloudevent_has_a_default_source(): 6 | assert CloudEvent(type="my.type.v1").source == DEFAULT_SOURCE 7 | -------------------------------------------------------------------------------- /tests/test_cloudevent_request.py: -------------------------------------------------------------------------------- 1 | from typing import AsyncGenerator 2 | 3 | import pytest 4 | from cloudevents.exceptions import MissingRequiredFields 5 | from cloudevents.http import CloudEvent 6 | 7 | from fastapi_cloudevents import CloudEventRequest, CloudEventSettings 8 | from fastapi_cloudevents.cloudevent_request import ( 9 | _best_effort_fix_json_data_payload, 10 | _should_fix_json_data_payload, 11 | ) 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "given, expected", 16 | [ 17 | (CloudEvent(attributes={"source": "a", "type": "a"}, data="{}"), True), 18 | ( 19 | CloudEvent( 20 | attributes={ 21 | "source": "a", 22 | "type": "a", 23 | "datacontenttype": "application/json", 24 | }, 25 | data="{}", 26 | ), 27 | True, 28 | ), 29 | ( 30 | CloudEvent( 31 | attributes={ 32 | "source": "a", 33 | "type": "a", 34 | "datacontenttype": "plain/text", 35 | }, 36 | data="{}", 37 | ), 38 | False, 39 | ), 40 | ( 41 | CloudEvent( 42 | attributes={ 43 | "source": "a", 44 | "type": "a", 45 | "datacontenttype": "application/json", 46 | }, 47 | data={}, 48 | ), 49 | False, 50 | ), 51 | ( 52 | CloudEvent( 53 | attributes={ 54 | "source": "a", 55 | "type": "a", 56 | "datacontenttype": "plain/text", 57 | }, 58 | data={}, 59 | ), 60 | False, 61 | ), 62 | ], 63 | ) 64 | def test_should_fix_payload_matches_golden_sample(given, expected): 65 | assert _should_fix_json_data_payload(given) == expected 66 | 67 | 68 | def test_best_effort_event_fixing_should_not_fail_on_invalid_json(): 69 | corrupt_json = "{" 70 | assert ( 71 | _best_effort_fix_json_data_payload( 72 | CloudEvent(attributes={"source": "a", "type": "a"}, data=corrupt_json) 73 | ).data 74 | == corrupt_json 75 | ) 76 | 77 | 78 | def test_best_effort_event_fixing_should_fix_valid_json(): 79 | assert _best_effort_fix_json_data_payload( 80 | CloudEvent(attributes={"source": "a", "type": "a"}, data='{"hello": "world"}') 81 | ).data == {"hello": "world"} 82 | 83 | 84 | class FakeInvalidCloudEventRequest(CloudEventRequest): 85 | def __init__(self): 86 | super(FakeInvalidCloudEventRequest, self).__init__( 87 | {"type": "http", "headers": []} 88 | ) 89 | 90 | async def stream(self) -> AsyncGenerator[bytes, None]: 91 | yield b'{"a": "b"}' 92 | 93 | 94 | @pytest.mark.asyncio 95 | async def test_when_disallowed_body_access_of_invalid_cloudevent_request_must_fail(): 96 | with pytest.raises(MissingRequiredFields): 97 | await FakeInvalidCloudEventRequest.configured( 98 | CloudEventSettings(allow_non_cloudevent_models=False) 99 | )().body() 100 | -------------------------------------------------------------------------------- /tests/test_cloudevent_response.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from cloudevents.exceptions import MissingRequiredFields 4 | from typing import AnyStr, Dict 5 | 6 | import pytest 7 | 8 | from fastapi_cloudevents import ( 9 | BinaryCloudEventResponse, 10 | StructuredCloudEventResponse, 11 | CloudEventSettings, 12 | ) 13 | from fastapi_cloudevents.cloudevent import DEFAULT_SOURCE 14 | from fastapi_cloudevents.cloudevent_response import ( 15 | RawHeaders, 16 | _encoded_string, 17 | _update_headers, 18 | ) 19 | 20 | 21 | def test_bytes_is_already_encoded(): 22 | v = b"Hello World" 23 | assert _encoded_string(v) is v 24 | 25 | 26 | def test_str_should_be_encoded_in_utf_8(): 27 | assert _encoded_string("Hello World") == b"Hello World" 28 | 29 | 30 | @pytest.mark.parametrize( 31 | "given_headers, new_headers, expected", 32 | [ 33 | ( 34 | [(b"ce-source", b"my-source"), (b"content-type", b"application/json")], 35 | {"ce-source": "your-source"}, 36 | [(b"ce-source", b"your-source"), (b"content-type", b"application/json")], 37 | ), 38 | ( 39 | [(b"ce-source", b"my-source"), (b"content-type", b"application/json")], 40 | {"Content-Type": b"plain/text"}, 41 | [(b"ce-source", b"my-source"), (b"content-type", b"plain/text")], 42 | ), 43 | ], 44 | ) 45 | def test_update_headers_match_golden_sample( 46 | given_headers: RawHeaders, new_headers: Dict[AnyStr, AnyStr], expected: RawHeaders 47 | ): 48 | result = _update_headers(given_headers, new_headers) 49 | assert set(result) == set(expected) 50 | 51 | 52 | def test_re_rendering_structured_response_should_update_content_length(): 53 | response = StructuredCloudEventResponse({"a": "b"}) 54 | old_content_length = dict(response.raw_headers)[b"content-length"] 55 | response._re_render({"a": "b", "c": "d"}) 56 | assert dict(response.raw_headers)[b"content-length"] != old_content_length 57 | 58 | 59 | @pytest.mark.parametrize( 60 | "given, expected_body, expected_headers", 61 | [ 62 | pytest.param( 63 | StructuredCloudEventResponse({"source": "non-default-source"}), 64 | {"source": "non-default-source"}, 65 | [ 66 | (b"content-length", b"31"), 67 | (b"content-type", b"application/cloudevents+json"), 68 | ], 69 | id="non default source must not be replaced", 70 | ), 71 | pytest.param( 72 | StructuredCloudEventResponse({"source": DEFAULT_SOURCE}), 73 | {"source": "new-source"}, 74 | [ 75 | (b"content-length", b"23"), 76 | (b"content-type", b"application/cloudevents+json"), 77 | ], 78 | id="default source should be replaced", 79 | ), 80 | pytest.param( 81 | StructuredCloudEventResponse( 82 | {"source": "dummy" + DEFAULT_SOURCE + "another"} 83 | ), 84 | {"source": "dummyfastapianother"}, 85 | [ 86 | (b"content-length", b"32"), 87 | (b"content-type", b"application/cloudevents+json"), 88 | ], 89 | id=( 90 | "sources which contain the default source as a sub string must not be " 91 | "affected" 92 | ), 93 | ), 94 | ], 95 | ) 96 | def test_structured_replace_default_source_matches_golden_sample( 97 | given: StructuredCloudEventResponse, 98 | expected_body: Dict[str, str], 99 | expected_headers: RawHeaders, 100 | ): 101 | given.replace_default_source("new-source") 102 | assert json.loads(given.body) == expected_body 103 | assert set(given.raw_headers) == set(expected_headers) 104 | 105 | 106 | @pytest.mark.parametrize( 107 | "given, expected_headers", 108 | [ 109 | pytest.param( 110 | BinaryCloudEventResponse( 111 | { 112 | "source": "non-default-source", 113 | "type": "dummy", 114 | "id": "1", 115 | "time": "1", 116 | } 117 | ), 118 | [ 119 | (b"ce-id", b"1"), 120 | (b"ce-source", b"non-default-source"), 121 | (b"ce-specversion", b"1.0"), 122 | (b"ce-time", b"1"), 123 | (b"ce-type", b"dummy"), 124 | (b"content-type", b"application/json"), 125 | (b"content-length", b"4"), 126 | ], 127 | id="non default source must not be replaced", 128 | ), 129 | pytest.param( 130 | BinaryCloudEventResponse( 131 | {"source": DEFAULT_SOURCE, "type": "dummy", "id": "1", "time": "1"} 132 | ), 133 | [ 134 | (b"ce-id", b"1"), 135 | (b"ce-source", b"new-source"), 136 | (b"ce-specversion", b"1.0"), 137 | (b"ce-time", b"1"), 138 | (b"ce-type", b"dummy"), 139 | (b"content-type", b"application/json"), 140 | (b"content-length", b"4"), 141 | ], 142 | id="default source should be replaced", 143 | ), 144 | pytest.param( 145 | BinaryCloudEventResponse( 146 | { 147 | "source": "dummy" + DEFAULT_SOURCE + "another", 148 | "type": "dummy", 149 | "id": "1", 150 | "time": "1", 151 | } 152 | ), 153 | [ 154 | (b"ce-id", b"1"), 155 | (b"ce-source", b"dummyfastapianother"), 156 | (b"ce-specversion", b"1.0"), 157 | (b"ce-time", b"1"), 158 | (b"ce-type", b"dummy"), 159 | (b"content-type", b"application/json"), 160 | (b"content-length", b"4"), 161 | ], 162 | id=( 163 | "sources which contain the default source as a sub string must not be " 164 | "affected" 165 | ), 166 | ), 167 | ], 168 | ) 169 | def test_binary_replace_default_source_matches_golden_sample( 170 | given: BinaryCloudEventResponse, 171 | expected_headers: RawHeaders, 172 | ): 173 | given.replace_default_source("new-source") 174 | assert set(given.raw_headers) == set(expected_headers) 175 | 176 | 177 | @pytest.mark.parametrize( 178 | "content_type, expected_value", 179 | [ 180 | (None, b"null"), 181 | ("plain/text", b""), 182 | ("application/json", b"null"), 183 | ("dummy/dummy+json", b"null"), 184 | ], 185 | ) 186 | def test_binary_response_given_empty_data_return_golden_empty_value( 187 | content_type, expected_value 188 | ): 189 | assert ( 190 | BinaryCloudEventResponse( 191 | { 192 | "source": "dummy", 193 | "type": "dummy", 194 | "data": None, 195 | "datacontenttype": content_type, 196 | } 197 | ).body 198 | == expected_value 199 | ) 200 | 201 | 202 | _INVALID_CLOUDEVENT_CONTENT = {"a": "b"} 203 | 204 | 205 | def test_when_allowed_rendering_invalid_cloudevent_binary_response_must_rendered_to_json(): 206 | CloudEventSettings(allow_non_cloudevent_models=True) 207 | assert ( 208 | json.loads( 209 | BinaryCloudEventResponse.configured( 210 | CloudEventSettings(allow_non_cloudevent_models=True) 211 | )({}).render(_INVALID_CLOUDEVENT_CONTENT) 212 | ) 213 | == _INVALID_CLOUDEVENT_CONTENT 214 | ) 215 | 216 | 217 | def test_when_disallowed_rendering_invalid_cloudevent_binary_response_must_fail(): 218 | with pytest.raises(MissingRequiredFields): 219 | BinaryCloudEventResponse.configured( 220 | CloudEventSettings(allow_non_cloudevent_models=False) 221 | )({}).render(_INVALID_CLOUDEVENT_CONTENT) 222 | 223 | 224 | def test_when_disallowed_rendering_invalid_cloudevent_binary_headers_must_fail(): 225 | with pytest.raises(MissingRequiredFields): 226 | BinaryCloudEventResponse.configured( 227 | CloudEventSettings(allow_non_cloudevent_models=False) 228 | )._render_headers(_INVALID_CLOUDEVENT_CONTENT, []) 229 | -------------------------------------------------------------------------------- /tests/test_cloudevent_route.py: -------------------------------------------------------------------------------- 1 | from fastapi_cloudevents.cloudevent_route import CloudEventRoute 2 | from fastapi_cloudevents.settings import CloudEventSettings 3 | 4 | 5 | def test_route_is_configurable(): 6 | dummy_settings = CloudEventSettings() 7 | assert CloudEventRoute._settings is not dummy_settings 8 | assert CloudEventRoute.configured(dummy_settings)._settings == dummy_settings 9 | -------------------------------------------------------------------------------- /tests/test_content_type.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fastapi_cloudevents.content_type import _is_json_content_type 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "given, expected", 8 | [ 9 | (None, True), 10 | ("application/json", True), 11 | ("application/xml", False), 12 | ("json", False), 13 | ("plain/text", False), 14 | ("plain/json", True), 15 | ("application/something+json", True), 16 | ("application/cloudevents+json", True), 17 | ("dummy/json", True), 18 | ("dummy/dummy+json", True), 19 | ("plain/dummy+json", True), 20 | ("/dummy+json", True), 21 | ("dummy/+json", True), 22 | ("/+json", True), 23 | ("/json", True), 24 | ], 25 | ) 26 | def test_is_json_content_type_matches_golden_samples(given, expected): 27 | assert _is_json_content_type(given) == expected 28 | -------------------------------------------------------------------------------- /tests/test_docs.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | repo_root = Path(__file__).parent.parent 7 | 8 | 9 | readme = (repo_root / "README.md").read_text() 10 | tox_config = (repo_root / "tox.ini").read_text() 11 | 12 | example_files_expected_to_appear_in_readme = [ 13 | repo_root / "examples/simple_server/example_server.py", 14 | repo_root / "examples/simple_server/example_binary_request.sh", 15 | repo_root / "examples/simple_server/example_structured_request.sh", 16 | repo_root / "examples/simple_server/example_response.txt", 17 | repo_root / "examples/structured_response_server/example_server.py", 18 | repo_root / "examples/structured_response_server/example_request.sh", 19 | repo_root / "examples/structured_response_server/example_response.txt", 20 | repo_root / "examples/type_routing/example_server.py", 21 | ] 22 | 23 | 24 | @pytest.mark.parametrize( 25 | "example_file", 26 | [ 27 | pytest.param(path.read_text(), id=str(path)) 28 | for path in example_files_expected_to_appear_in_readme 29 | ], 30 | ) 31 | def test_certain_examples_should_appear_in_readme(example_file: str): 32 | assert example_file in readme 33 | 34 | 35 | def test_coverage_badge_must_represent_correct_coverage(): 36 | """ 37 | It is easier to enforce a strict coverage in our tests and project it into 38 | the documentation rather then to relay on third party Coverage calculation systems. 39 | 40 | This test exists to make sure that future developers will not update one thing and 41 | not the other. 42 | """ 43 | enforced_coverage = int( 44 | re.search(r"--cov-fail-under=(?P[0-9]+)", tox_config).groupdict()[ 45 | "percent" 46 | ] 47 | ) 48 | 49 | badge_coverage = int( 50 | re.search( 51 | r"https://img.shields.io/badge/" 52 | r"coverage-(?P[0-9]+)%25-brightgreen", 53 | readme, 54 | ).groupdict()["percent"] 55 | ) 56 | assert badge_coverage == enforced_coverage 57 | -------------------------------------------------------------------------------- /tests/test_examples/test_custom_source_tag.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from cloudevents.http import from_http 3 | from starlette.testclient import TestClient 4 | 5 | from examples.custom_default_source.example_server import app 6 | 7 | 8 | @pytest.fixture() 9 | def client(): 10 | return TestClient(app) 11 | 12 | 13 | def test_binary_request_is_in_binary_format(client): 14 | for i in range(10): # example server has random element 15 | response = client.get("/") 16 | assert response.status_code == 200 17 | event = from_http(response.headers, response.content) 18 | assert event.get("source") in ("my-source", "his-source") 19 | -------------------------------------------------------------------------------- /tests/test_examples/test_events_and_basemodels_mixed.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from cloudevents.conversion import to_binary, to_structured 3 | from cloudevents.pydantic import CloudEvent, from_http 4 | from starlette.testclient import TestClient 5 | 6 | from examples.events_and_basemodels_mixed.example_server import app 7 | 8 | 9 | @pytest.fixture() 10 | def client(): 11 | return TestClient(app) 12 | 13 | 14 | def test_event_response_should_be_a_cloudevent(client): 15 | response = client.post("/event-response", data='{"my_value": "Hello World"}') 16 | assert from_http(headers=response.headers, data=response.content).data == { 17 | "my_value": "Hello World" 18 | } 19 | 20 | 21 | def test_model_response_should_be_a_simple_model(client): 22 | response = client.post("/event-response", data='{"my_value": "Hello World"}') 23 | assert response.json() == {"my_value": "Hello World"} 24 | -------------------------------------------------------------------------------- /tests/test_examples/test_simple_server.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from cloudevents.conversion import to_binary, to_structured 5 | from cloudevents.http import CloudEvent 6 | from starlette.testclient import TestClient 7 | 8 | from examples.simple_server.example_server import app 9 | 10 | 11 | @pytest.fixture() 12 | def client(): 13 | return TestClient(app) 14 | 15 | 16 | _DUMMY_SOURCE = "my-source" 17 | _DUMMY_CONTENT_TYPE = "text/plain" 18 | _DUMMY_TYPE = "my.event.v1" 19 | _DUMMY_DATA = "Hello World" 20 | _EXPECTED_HEADERS = { 21 | "content-length", 22 | "content-type", 23 | "ce-specversion", 24 | "ce-id", 25 | "ce-source", 26 | "ce-type", 27 | "ce-time", 28 | } 29 | _EXPECTED_RESPONSE_HEADER_VALUES = { 30 | "content-length": str(len(json.dumps(_DUMMY_DATA))), 31 | "content-type": _DUMMY_CONTENT_TYPE, 32 | "ce-specversion": "1.0", 33 | "ce-source": "http://testserver/", 34 | "ce-type": "my.response-type.v1", 35 | } 36 | 37 | 38 | @pytest.mark.parametrize("to_http", (to_binary, to_structured)) 39 | def test_binary_request_is_in_binary_format(client, to_http): 40 | headers, data = to_http( 41 | CloudEvent( 42 | { 43 | "type": _DUMMY_TYPE, 44 | "source": _DUMMY_SOURCE, 45 | "datacontenttype": _DUMMY_CONTENT_TYPE, 46 | }, 47 | _DUMMY_DATA, 48 | ) 49 | ) 50 | response = client.post("/", headers=headers, data=data) 51 | assert response.status_code == 200 52 | assert set(response.headers.keys()) == _EXPECTED_HEADERS 53 | assert { 54 | k: v 55 | for k, v in response.headers.items() 56 | if k in _EXPECTED_RESPONSE_HEADER_VALUES 57 | } == _EXPECTED_RESPONSE_HEADER_VALUES 58 | assert response.json() == _DUMMY_DATA 59 | -------------------------------------------------------------------------------- /tests/test_examples/test_structured_response_server.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from cloudevents.conversion import to_binary, to_structured 3 | from cloudevents.http import CloudEvent 4 | from starlette.testclient import TestClient 5 | 6 | from examples.structured_response_server.example_server import app 7 | 8 | 9 | @pytest.fixture() 10 | def client(): 11 | return TestClient(app) 12 | 13 | 14 | _DUMMY_SOURCE = "my-source" 15 | _DUMMY_TYPE = "my.event.v1" 16 | _DUMMY_JSON_DATA = {"a": "b"} 17 | _EXPECTED_KEYS = { 18 | "content-length", 19 | "content-type", 20 | "ce-specversion", 21 | "ce-id", 22 | "ce-source", 23 | "ce-type", 24 | "ce-time", 25 | } 26 | _EXPECTED_RESPONSE_HEADER_VALUES = { 27 | "specversion": "1.0", 28 | "source": "http://testserver/", 29 | "type": "com.my-corp.response.v1", 30 | "data": {"a": "b"}, 31 | } 32 | 33 | 34 | @pytest.mark.parametrize("to_http", (to_binary, to_structured)) 35 | def test_structured_responses_should_contain_the_event_in_the_data(client, to_http): 36 | headers, data = to_http( 37 | CloudEvent({"type": _DUMMY_TYPE, "source": _DUMMY_SOURCE}, _DUMMY_JSON_DATA) 38 | ) 39 | response = client.post("/", headers=headers, data=data) 40 | assert response.status_code == 200 41 | assert response.headers["content-type"] == "application/cloudevents+json" 42 | 43 | assert { 44 | k: v 45 | for k, v in response.json().items() 46 | if k in _EXPECTED_RESPONSE_HEADER_VALUES 47 | } == _EXPECTED_RESPONSE_HEADER_VALUES 48 | -------------------------------------------------------------------------------- /tests/test_examples/test_type_routing.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from cloudevents.conversion import to_binary, to_structured 3 | from cloudevents.pydantic import CloudEvent, from_http 4 | from starlette.testclient import TestClient 5 | import sys 6 | 7 | if sys.version_info[1] >= 8: 8 | from examples.type_routing.example_server import app 9 | 10 | @pytest.fixture() 11 | def client(): 12 | return TestClient(app) 13 | 14 | @pytest.mark.parametrize( 15 | "given_type, expected_type", 16 | [ 17 | ("my.type.v1", "my.response-type.v1"), 18 | ("your.type.v1", "your.response-type.v1"), 19 | ], 20 | ) 21 | @pytest.mark.parametrize("to_http", (to_binary, to_structured)) 22 | def test_binary_request_is_in_binary_format( 23 | client, to_http, given_type, expected_type 24 | ): 25 | headers, data = to_http(CloudEvent(type=given_type, source="dummy-source")) 26 | response = client.post("/", headers=headers, data=data) 27 | assert ( 28 | from_http(headers=response.headers, data=response.content).type 29 | == expected_type 30 | ) 31 | -------------------------------------------------------------------------------- /tests/test_installation.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import FastAPI 3 | from fastapi.responses import ORJSONResponse 4 | 5 | from fastapi_cloudevents import CloudEventSettings, install_fastapi_cloudevents 6 | from fastapi_cloudevents.installation import _choose_default_response_class 7 | 8 | 9 | def test_user_warned_when_overriding_default_response_object(caplog): 10 | app = FastAPI(default_response_class=ORJSONResponse) 11 | install_fastapi_cloudevents(app) 12 | assert "WARNING" in caplog.text 13 | 14 | 15 | def test_choose_default_response_class_invalid_option_must_fail(): 16 | settings = CloudEventSettings() 17 | settings.default_response_mode = object() 18 | with pytest.raises(ValueError): 19 | _choose_default_response_class(settings) 20 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{37,38,39,310},lint 3 | skipsdist = True 4 | 5 | [testenv] 6 | usedevelop = True 7 | deps = 8 | -r{toxinidir}/requirements.txt 9 | -r{toxinidir}/tests/requirements.txt 10 | setenv = 11 | PYTESTARGS = -v -s --tb=long --cov=fastapi_cloudevents --cov-report term-missing 12 | --cov-fail-under=100 13 | commands = pytest {env:PYTESTARGS} {posargs} 14 | 15 | [testenv:reformat] 16 | basepython = python3.10 17 | deps = 18 | black 19 | isort 20 | commands = 21 | black . 22 | isort cloudevents samples 23 | 24 | [testenv:lint] 25 | basepython = python3.10 26 | deps = 27 | black 28 | isort 29 | flake8 30 | commands = 31 | black --check . 32 | isort -c cloudevents samples 33 | flake8 tests --ignore W503,E731 --extend-ignore E203 --max-line-length 88 34 | --------------------------------------------------------------------------------