fusion 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ # This workflow will upload a Python Package using Twine when a release is created
2
+ # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
3
+
4
+ # This workflow uses actions that are not certified by GitHub.
5
+ # They are provided by a third-party and are governed by
6
+ # separate terms of service, privacy policy, and support
7
+ # documentation.
8
+
9
+ name: Upload Python Package
10
+
11
+ on:
12
+ release:
13
+ types: [published]
14
+
15
+ permissions:
16
+ contents: read
17
+
18
+ jobs:
19
+ release-build:
20
+ runs-on: ubuntu-latest
21
+
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+
25
+ - uses: actions/setup-python@v5
26
+ with:
27
+ python-version: "3.12"
28
+
29
+ - name: Build release distributions
30
+ run: |
31
+ # NOTE: put your own distribution build steps here.
32
+ python -m pip install build
33
+ python -m build
34
+
35
+ - name: Upload distributions
36
+ uses: actions/upload-artifact@v4
37
+ with:
38
+ name: release-dists
39
+ path: dist/
40
+
41
+ pypi-publish:
42
+ runs-on: ubuntu-latest
43
+
44
+ needs:
45
+ - release-build
46
+
47
+ permissions:
48
+ # IMPORTANT: this permission is mandatory for trusted publishing
49
+ id-token: write
50
+
51
+ #environment:
52
+ # name: testpypi
53
+ # url: https://test.pypi.org/p/fusion
54
+ environment:
55
+ name: pypi
56
+ url: https://pypi.org/p/fusion
57
+
58
+ steps:
59
+ - name: Retrieve release distributions
60
+ uses: actions/download-artifact@v4
61
+ with:
62
+ name: release-dists
63
+ path: dist/
64
+
65
+ - name: Publish release distributions to PyPI
66
+ uses: pypa/gh-action-pypi-publish@release/v1
67
+
68
+ #- name: Publish release distributions to TestPyPI
69
+ # uses: pypa/gh-action-pypi-publish@release/v1
70
+ # with:
71
+ # repository-url: https://test.pypi.org/legacy/
@@ -0,0 +1,168 @@
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
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
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
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # poetry
98
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102
+ #poetry.lock
103
+
104
+ # pdm
105
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106
+ #pdm.lock
107
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108
+ # in version control.
109
+ # https://pdm.fming.dev/#use-with-ide
110
+ .pdm.toml
111
+
112
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113
+ __pypackages__/
114
+
115
+ # Celery stuff
116
+ celerybeat-schedule
117
+ celerybeat.pid
118
+
119
+ # SageMath parsed files
120
+ *.sage.py
121
+
122
+ # Environments
123
+ .env
124
+ .venv
125
+ env/
126
+ venv/
127
+ ENV/
128
+ env.bak/
129
+ venv.bak/
130
+ .vscode/
131
+
132
+ # Spyder project settings
133
+ .spyderproject
134
+ .spyproject
135
+
136
+ # Rope project settings
137
+ .ropeproject
138
+
139
+ # mkdocs documentation
140
+ /site
141
+
142
+ # mypy
143
+ .mypy_cache/
144
+ .dmypy.json
145
+ dmypy.json
146
+
147
+ # ruff
148
+ .ruff_cache/
149
+
150
+ # Pyre type checker
151
+ .pyre/
152
+
153
+ # pytype static type analyzer
154
+ .pytype/
155
+
156
+ # Cython debug symbols
157
+ cython_debug/
158
+
159
+ # PyCharm
160
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
161
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
162
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
163
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
164
+ #.idea/
165
+
166
+ # Zed code editor
167
+ # pyright
168
+ pyrightconfig.json
@@ -0,0 +1,49 @@
1
+ default_language_version:
2
+ python: "3.12"
3
+ repos:
4
+ - hooks:
5
+ - args:
6
+ - --strict
7
+ - feat
8
+ - fix
9
+ - chore
10
+ - test
11
+ - bump
12
+ id: conventional-pre-commit
13
+ stages:
14
+ - commit-msg
15
+ repo: https://github.com/compilerla/conventional-pre-commit
16
+ rev: v3.0.0
17
+ - hooks:
18
+ - id: check-yaml
19
+ - id: end-of-file-fixer
20
+ - id: trailing-whitespace
21
+ repo: https://github.com/pre-commit/pre-commit-hooks
22
+ rev: v2.3.0
23
+ - hooks:
24
+ - args:
25
+ - --fix
26
+ id: ruff
27
+ - id: ruff-format
28
+ repo: https://github.com/astral-sh/ruff-pre-commit
29
+ rev: v0.1.8
30
+ - hooks:
31
+ - additional_dependencies:
32
+ - bandit[toml]
33
+ args:
34
+ - -c
35
+ - pyproject.toml
36
+ - --exclude
37
+ - tests/*
38
+ id: bandit
39
+ repo: https://github.com/PyCQA/bandit
40
+ rev: 1.7.6
41
+
42
+ - repo: local
43
+ hooks:
44
+ - id: pytest-check
45
+ name: pytest-check
46
+ entry: pytest --cov
47
+ language: system
48
+ pass_filenames: false
49
+ always_run: true
File without changes
File without changes
fusion-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: fusion
3
+ Version: 0.1.0
4
+ Summary: Fusion is a modern ASGI web framework for Python with built-in dependency injection, OpenAPI schema generation, and MCP support.
5
+ Project-URL: Homepage, https://github.com/okanakbulut/fusion
6
+ Author: Okan Akbulut
7
+ License-File: LICENSE.md
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: msgspec==0.19.0
10
+ Requires-Dist: starlette==0.47.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: commitizen==4.8.2; extra == 'dev'
13
+ Requires-Dist: coverage[toml]>=6.5; extra == 'dev'
14
+ Requires-Dist: httpx; extra == 'dev'
15
+ Requires-Dist: ipython; extra == 'dev'
16
+ Requires-Dist: pre-commit==4.2.0; extra == 'dev'
17
+ Requires-Dist: pytest; extra == 'dev'
18
+ Requires-Dist: pytest-asyncio; extra == 'dev'
19
+ Requires-Dist: pytest-cov; extra == 'dev'
20
+ Requires-Dist: pytest-mock; extra == 'dev'
21
+ Requires-Dist: ruff==0.11.12; extra == 'dev'
fusion-0.1.0/README.md ADDED
File without changes
@@ -0,0 +1,106 @@
1
+ [project]
2
+ name = "fusion"
3
+ version = "0.1.0"
4
+ description = "Fusion is a modern ASGI web framework for Python with built-in dependency injection, OpenAPI schema generation, and MCP support."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { file = "LICENSE.md" }
8
+ authors = [
9
+ { name="Okan Akbulut" }
10
+ ]
11
+ dependencies = [
12
+ "msgspec==0.19.0",
13
+ "starlette==0.47.0"
14
+ ]
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/okanakbulut/fusion"
18
+
19
+
20
+ [project.optional-dependencies]
21
+ dev = [
22
+ "coverage[toml]>=6.5",
23
+ "pytest",
24
+ "pytest-asyncio",
25
+ "pytest-cov",
26
+ "pytest-mock",
27
+ "pre-commit==4.2.0",
28
+ "ruff==0.11.12",
29
+ "commitizen==4.8.2",
30
+ "ipython",
31
+ "httpx",
32
+ ]
33
+
34
+ [build-system]
35
+ requires = ["hatchling"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.ruff]
39
+ line-length = 100
40
+ indent-width = 4
41
+ target-version = "py312"
42
+ src = ["src", "tests"]
43
+ unfixable = ["F401"]
44
+
45
+
46
+ [tool.ruff.lint]
47
+ fixable = ["ALL"]
48
+ extend-select = ["C90", "I", "F", "E", "ASYNC"]
49
+ extend-ignore = ["F401"]
50
+
51
+ [tool.ruff.per-file-ignores]
52
+ "**/__init__.py" = ["F401"]
53
+ "**/tests/**" = ["D101", "D102", "D103", "F", "E501"]
54
+ "**/tests/sql/**" = ["E501"]
55
+
56
+
57
+ [tool.ruff.lint.pydocstyle]
58
+ convention = "google"
59
+
60
+ [tool.ruff.lint.mccabe]
61
+ # Flag errors (`C901`) whenever the complexity level exceeds 15.
62
+ max-complexity = 15
63
+
64
+
65
+ [tool.commitizen]
66
+ name = "cz_conventional_commits"
67
+ tag_format = "v$version"
68
+ version_scheme = "semver"
69
+ version_provider = "pep621"
70
+ update_changelog_on_bump = true
71
+ major_version_zero = true
72
+
73
+ [tool.pytest.ini_options]
74
+ addopts = [
75
+ "--import-mode=importlib",
76
+ "--doctest-modules",
77
+ "--quiet",
78
+ "--tb=long",
79
+ ]
80
+ testpaths = ["tests", "src/fusion"]
81
+
82
+ [tool.coverage.run]
83
+ branch = true
84
+ omit = ["tests/*"]
85
+
86
+ [tool.coverage.report]
87
+ exclude_lines = [
88
+ "pragma: no cover",
89
+ "def __repr__",
90
+ "def __str__",
91
+ "if self.debug",
92
+ "if settings.DEBUG",
93
+ "raise AssertionError",
94
+ "raise NotImplementedError",
95
+ "if 0:",
96
+ "if __name__ == .__main__.:",
97
+ ]
98
+ #fail_under = 100
99
+ #show_missing = true
100
+ #skip_covered = true
101
+
102
+ [tool.pyright]
103
+ venvPath = "/Users/okanakbulut/.virtualenvs"
104
+ venv = "fusion"
105
+ pythonVersion = "3.12"
106
+ exclude = ["**/__init__.py", "**/__pycache__"]
@@ -0,0 +1,5 @@
1
+ from .annotations import Cookie, Header, PathParam, QueryParam, RequestBody
2
+ from .application import Fusion
3
+ from .di import Injectable, factory
4
+ from .endpoints import HttpEndpoint
5
+ from .routing import Route
@@ -0,0 +1,15 @@
1
+ from typing import Annotated
2
+
3
+ from fusion.resolvers import (
4
+ CookieResolver,
5
+ HeaderResolver,
6
+ PathParamResolver,
7
+ QueryParamResolver,
8
+ RequestBodyResolver,
9
+ )
10
+
11
+ type PathParam[T] = Annotated[T, {"resolver": PathParamResolver}]
12
+ type QueryParam[T] = Annotated[T, {"resolver": QueryParamResolver}]
13
+ type Header[T] = Annotated[T, {"resolver": HeaderResolver}]
14
+ type Cookie[T] = Annotated[T, {"resolver": CookieResolver}]
15
+ type RequestBody[O] = Annotated[O, {"resolver": RequestBodyResolver}]
@@ -0,0 +1,11 @@
1
+ from starlette.applications import Starlette
2
+
3
+ from fusion.routing import Router
4
+
5
+
6
+ class Fusion(Starlette):
7
+ """Fusion application that integrates dependency injection."""
8
+
9
+ def __init__(self, routes, middleware=None, lifespan=None):
10
+ super().__init__(routes=routes, middleware=middleware, lifespan=lifespan)
11
+ self.router = Router(routes=routes, lifespan=lifespan)
@@ -0,0 +1,28 @@
1
+ from contextlib import AsyncExitStack
2
+ from contextvars import ContextVar, Token
3
+ from typing import Self
4
+
5
+ from starlette.requests import Request
6
+
7
+ context: ContextVar["Context"] = ContextVar("context")
8
+
9
+
10
+ class Context(AsyncExitStack):
11
+ _token: Token
12
+ request: Request
13
+
14
+ def __init__(self, request: Request):
15
+ super().__init__()
16
+ self.request = request
17
+
18
+ async def __aenter__(self) -> Self:
19
+ if context.get(None) is not None:
20
+ raise RuntimeError("Nested context is not allowed")
21
+ self._token = context.set(self)
22
+ return await super().__aenter__()
23
+
24
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: # type:ignore
25
+ try:
26
+ await super().__aexit__(exc_type, exc_val, exc_tb)
27
+ finally:
28
+ context.reset(self._token)
@@ -0,0 +1,102 @@
1
+ from collections.abc import AsyncIterator
2
+ from contextlib import AbstractAsyncContextManager
3
+ from functools import wraps
4
+ from typing import Any, Callable, ClassVar, Self, TypeVar, get_origin
5
+
6
+ from msgspec import Struct as Object
7
+
8
+ from fusion.resolvers import (
9
+ Constructor,
10
+ FactoryResolver,
11
+ InjectableResolver,
12
+ Resolver,
13
+ __factories__,
14
+ )
15
+
16
+ T = TypeVar("T")
17
+
18
+
19
+ class Injectable(Object):
20
+ __resolvers__: ClassVar[list[Resolver]]
21
+
22
+ def __init_subclass__(cls, *args, **kwargs):
23
+ cls.__resolvers__ = build_resolvers(cls.__annotations__)
24
+ super().__init_subclass__(*args, **kwargs)
25
+
26
+ @classmethod
27
+ async def instance(cls) -> Self:
28
+ """Create an instance of the class with all dependencies resolved."""
29
+ params = {}
30
+ for resolver in cls.__resolvers__:
31
+ name, value = await resolver.resolve()
32
+ params[name] = value
33
+
34
+ return cls(**params)
35
+
36
+
37
+ def factory(func: Constructor) -> Constructor:
38
+ """Decorator to register a factory function for a type."""
39
+ if "return" not in func.__annotations__:
40
+ raise ValueError("Factory function must have a return type annotation")
41
+ # Register the factory function
42
+ return_annotation = func.__annotations__["return"]
43
+ origin = get_origin(return_annotation)
44
+ return_type = return_annotation.__args__[0] if origin is AsyncIterator else return_annotation
45
+ __factories__[return_type] = func
46
+ return func
47
+
48
+
49
+ def build_resolvers(annotations: dict[str, Any]) -> list[Resolver]:
50
+ resolvers = []
51
+ for name, annotation in annotations.items():
52
+ origin = get_origin(annotation)
53
+ if not origin:
54
+ if issubclass(annotation, Injectable):
55
+ resolvers.append(InjectableResolver(name=name, typ=annotation))
56
+ elif annotation in __factories__:
57
+ resolvers.append(FactoryResolver(name=name, typ=annotation))
58
+ else:
59
+ raise ValueError(f"Invalid annotation for {name}: {annotation}")
60
+ continue
61
+ # skip if annotation is ClassVar
62
+ if origin is ClassVar:
63
+ continue
64
+
65
+ if len(annotation.__args__) != 1:
66
+ raise ValueError(f"Invalid annotation for {name}: {annotation}")
67
+
68
+ typ = annotation.__args__[0]
69
+ annotated = origin.__value__
70
+ if not annotated:
71
+ raise ValueError(f"Invalid annotation for {name}: {annotation}")
72
+
73
+ if not hasattr(annotated, "__metadata__"):
74
+ raise ValueError(f"Invalid annotation for {name}: {annotation}")
75
+
76
+ metadata = annotated.__metadata__[0]
77
+ DependencyResolver = metadata.get("resolver", None)
78
+ if not DependencyResolver:
79
+ raise ValueError(f"No resolver found for {name}: {annotation}")
80
+
81
+ if not issubclass(DependencyResolver, Resolver):
82
+ raise ValueError(f"Invalid resolver for {name}: {annotation}")
83
+
84
+ resolvers.append(DependencyResolver(name=name, typ=typ))
85
+
86
+ return resolvers
87
+
88
+
89
+ def inject(func: Callable) -> Callable:
90
+ """Decorator to mark a function as an injector."""
91
+ resolvers = build_resolvers(func.__annotations__)
92
+
93
+ @wraps(func)
94
+ async def wrapper(self, *args, **kwargs):
95
+ params = {}
96
+ for resolver in resolvers:
97
+ name, value = await resolver.resolve()
98
+ params[name] = value
99
+ # Call the original function with resolved parameters
100
+ return await func(self, **params)
101
+
102
+ return wrapper
@@ -0,0 +1,17 @@
1
+ from typing import ClassVar
2
+
3
+ from fusion.di import Injectable, inject
4
+
5
+
6
+ class HttpEndpoint(Injectable):
7
+ methods: ClassVar[list[str]]
8
+
9
+ def __init_subclass__(cls, *args, **kwargs):
10
+ cls.methods = []
11
+ for http_method in ("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"):
12
+ method = getattr(cls, http_method.lower(), None)
13
+ if callable(method):
14
+ cls.methods.append(http_method)
15
+ setattr(cls, http_method.lower(), inject(method))
16
+
17
+ super().__init_subclass__(*args, **kwargs)
@@ -0,0 +1,2 @@
1
+ class ValidationError(Exception):
2
+ pass
@@ -0,0 +1,140 @@
1
+ from abc import abstractmethod
2
+ from collections.abc import AsyncIterator, Awaitable
3
+ from contextlib import AbstractAsyncContextManager
4
+ from typing import (
5
+ Callable,
6
+ Generic,
7
+ Protocol,
8
+ Self,
9
+ Type,
10
+ TypeVar,
11
+ get_origin,
12
+ )
13
+
14
+ import msgspec
15
+ from msgspec import Struct as Object
16
+
17
+ from fusion.context import context
18
+
19
+ T = TypeVar("T")
20
+ type Constructor[T] = Callable[[], Awaitable[T] | AbstractAsyncContextManager[T]]
21
+ __factories__: dict[Type, Constructor] = {}
22
+
23
+
24
+ class InjectableObject(Protocol):
25
+ @classmethod
26
+ async def instance(cls) -> Self:
27
+ ...
28
+
29
+
30
+ class Resolver(Object, Generic[T]):
31
+ """Base class for resolvers."""
32
+
33
+ name: str
34
+ typ: Type[T]
35
+
36
+ @abstractmethod
37
+ async def resolve(self) -> tuple[str, T | None]:
38
+ """Resolve the dependency."""
39
+ raise NotImplementedError("Subclasses must implement this method")
40
+
41
+
42
+ class InjectableResolver(Resolver[InjectableObject]):
43
+ """Resolver for injected dependencies."""
44
+
45
+ async def resolve(self) -> tuple[str, InjectableObject]:
46
+ """Resolve the injected dependency."""
47
+ ctx = context.get()
48
+ if not ctx:
49
+ raise RuntimeError("Request context is not available")
50
+
51
+ return self.name, await self.typ.instance()
52
+
53
+
54
+ def isasynccontextmanager(func: Callable) -> bool:
55
+ assert hasattr(func, "__annotations__")
56
+ ret = func.__annotations__.get("return", None)
57
+ return get_origin(ret) is AsyncIterator if ret else False
58
+
59
+
60
+ class FactoryResolver(Resolver[T]):
61
+ """Resolver for factory functions."""
62
+
63
+ async def resolve(self) -> tuple[str, T]:
64
+ """Resolve the factory function."""
65
+ factory: Constructor | None = __factories__.get(self.typ)
66
+ if factory is None:
67
+ raise ValueError(f"No factory found for {self.typ}")
68
+
69
+ if isasynccontextmanager(factory):
70
+ ctx = context.get()
71
+ return self.name, await ctx.enter_async_context(factory()) # type: ignore
72
+ else:
73
+ return self.name, await factory() # type: ignore
74
+
75
+
76
+ class QueryParamResolver(Resolver[T]):
77
+ """Resolver for query parameters."""
78
+
79
+ async def resolve(self) -> tuple[str, T | None]:
80
+ """Resolve the query parameter from the request context."""
81
+ ctx = context.get()
82
+ value = ctx.request.query_params.get(self.name, None)
83
+ if value is not None:
84
+ value = msgspec.convert(value, self.typ, strict=False)
85
+ return self.name, value
86
+
87
+
88
+ class PathParamResolver(Resolver[T]):
89
+ """Resolver for path parameters."""
90
+
91
+ async def resolve(self) -> tuple[str, T | None]:
92
+ """Resolve the path parameter from the request context."""
93
+ ctx = context.get()
94
+ value = ctx.request.path_params.get(self.name, None)
95
+ if value is not None:
96
+ value = msgspec.convert(value, self.typ, strict=False)
97
+ return self.name, value
98
+
99
+
100
+ class RequestBodyResolver(Resolver[T]):
101
+ """Resolver for request body parameters."""
102
+
103
+ async def resolve(self) -> tuple[str, T]:
104
+ """Resolve the request body from the request context."""
105
+ ctx = context.get()
106
+ body = await ctx.request.json()
107
+ value = msgspec.convert(body, self.typ, strict=True)
108
+ return self.name, value
109
+
110
+
111
+ class HeaderResolver(Resolver[T]):
112
+ """Resolver for header."""
113
+
114
+ async def resolve(self) -> tuple[str, T | None]:
115
+ """Resolve the header parameter from the request context."""
116
+ ctx = context.get()
117
+ headers = {
118
+ key.lower().replace("-", "_").replace(" ", "_"): value
119
+ for key, value in ctx.request.headers.items()
120
+ }
121
+ value = headers.get(self.name, None)
122
+ if value is not None:
123
+ value = msgspec.convert(value, self.typ, strict=False)
124
+ return self.name, value
125
+
126
+
127
+ class CookieResolver(Resolver[T]):
128
+ """Resolver for cookie."""
129
+
130
+ async def resolve(self) -> tuple[str, T | None]:
131
+ """Resolve the cookie parameter from the request context."""
132
+ ctx = context.get()
133
+ cookies = {
134
+ key.lower().replace("-", "_").replace(" ", "_"): value
135
+ for key, value in ctx.request.cookies.items()
136
+ }
137
+ value = cookies.get(self.name, None)
138
+ if value is not None:
139
+ value = msgspec.convert(value, self.typ, strict=False)
140
+ return self.name, value
@@ -0,0 +1,42 @@
1
+ from typing import Type
2
+
3
+ from starlette.exceptions import HTTPException
4
+ from starlette.requests import Request
5
+ from starlette.responses import PlainTextResponse
6
+ from starlette.routing import Route as StarletteRoute
7
+ from starlette.routing import Router as StarletteRouter
8
+ from starlette.types import Receive, Scope, Send
9
+
10
+ from fusion.context import Context
11
+ from fusion.endpoints import HttpEndpoint
12
+
13
+
14
+ class Route(StarletteRoute):
15
+ """Custom route that integrates dependency injection."""
16
+
17
+ endpoint: HttpEndpoint
18
+
19
+ def __init__(self, path: str, endpoint: Type[HttpEndpoint]):
20
+ super().__init__(path=path, endpoint=endpoint, methods=endpoint.methods)
21
+
22
+ async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
23
+ if scope["method"] not in self.endpoint.methods:
24
+ headers = {"Allow": ", ".join(self.endpoint.methods)}
25
+ if "app" in scope:
26
+ raise HTTPException(status_code=405, headers=headers)
27
+ else:
28
+ response = PlainTextResponse("Method Not Allowed", status_code=405, headers=headers)
29
+ await response(scope, receive, send)
30
+ else:
31
+ request = Request(scope, receive, send)
32
+ async with Context(request):
33
+ endpoint = (
34
+ await self.endpoint.instance()
35
+ ) # initialize the endpoint with dependencies resolved
36
+ method = getattr(endpoint, scope["method"].lower())
37
+ response = await method()
38
+ await response(scope, receive, send)
39
+
40
+
41
+ class Router(StarletteRouter):
42
+ ...
File without changes