skeleton-framework 0.1.0__py3-none-any.whl

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.
Files changed (93) hide show
  1. skeleton/__init__.py +0 -0
  2. skeleton/cli.py +151 -0
  3. skeleton/client.py +269 -0
  4. skeleton/infrastructure/__init__.py +1 -0
  5. skeleton/infrastructure/bootstrap/__init__.py +0 -0
  6. skeleton/infrastructure/bootstrap/src/__init__.py +0 -0
  7. skeleton/infrastructure/bootstrap/src/commands/__init__.py +0 -0
  8. skeleton/infrastructure/bootstrap/src/commands/client/__init__.py +0 -0
  9. skeleton/infrastructure/bootstrap/src/commands/client/init.py +486 -0
  10. skeleton/infrastructure/bootstrap/src/commands/client/install.py +113 -0
  11. skeleton/infrastructure/bootstrap/src/commands/dev/__init__.py +0 -0
  12. skeleton/infrastructure/bootstrap/src/commands/dev/build.py +80 -0
  13. skeleton/infrastructure/bootstrap/src/commands/dev/init.py +80 -0
  14. skeleton/infrastructure/bootstrap/src/commands/dev/install.py +168 -0
  15. skeleton/infrastructure/bootstrap/src/commands/dev/pack.py +55 -0
  16. skeleton/infrastructure/bootstrap/src/config.py +54 -0
  17. skeleton/infrastructure/bootstrap/src/copy.py +56 -0
  18. skeleton/infrastructure/bootstrap/src/load.py +221 -0
  19. skeleton/infrastructure/bootstrap/src/templates/app/backend/requirements.txt +1 -0
  20. skeleton/infrastructure/bootstrap/src/templates/app/backend/routes.json +10 -0
  21. skeleton/infrastructure/bootstrap/src/templates/app/backend/src/__init__.py +0 -0
  22. skeleton/infrastructure/bootstrap/src/templates/app/backend/src/main.py +35 -0
  23. skeleton/infrastructure/bootstrap/src/templates/app/frontend/package.json +21 -0
  24. skeleton/infrastructure/bootstrap/src/templates/app/frontend/skeleton-config.json +29 -0
  25. skeleton/infrastructure/bootstrap/src/templates/app/frontend/src/index.html +16 -0
  26. skeleton/infrastructure/bootstrap/src/templates/app/frontend/src/main.tsx +15 -0
  27. skeleton/infrastructure/bootstrap/src/templates/app/frontend/src/static/css/styles.css +28 -0
  28. skeleton/infrastructure/bootstrap/src/templates/app/frontend/src/static/img/skeleton.png +0 -0
  29. skeleton/infrastructure/bootstrap/src/templates/app/frontend/src/static/js/script.js +1 -0
  30. skeleton/infrastructure/bootstrap/src/templates/app/frontend/tsconfig.json +90 -0
  31. skeleton/infrastructure/bootstrap/src/templates/config/.gitignore +21 -0
  32. skeleton/infrastructure/bootstrap/src/templates/config/skeleton-dependencies.json +21 -0
  33. skeleton/infrastructure/bootstrap/src/templates/config/skeleton-layout.json +11 -0
  34. skeleton/infrastructure/bootstrap/src/templates/config/skeleton-schemas.json +20 -0
  35. skeleton/infrastructure/bootstrap/src/templates/config/skeleton-servers.json +16 -0
  36. skeleton/infrastructure/common/__init__.py +1 -0
  37. skeleton/infrastructure/common/config.json +8 -0
  38. skeleton/infrastructure/common/src/__init__.py +0 -0
  39. skeleton/infrastructure/common/src/config.py +37 -0
  40. skeleton/infrastructure/common/src/constants.py +4 -0
  41. skeleton/infrastructure/common/src/context.py +15 -0
  42. skeleton/infrastructure/common/src/env.py +12 -0
  43. skeleton/infrastructure/common/src/layout.py +68 -0
  44. skeleton/infrastructure/common/src/loader.py +8 -0
  45. skeleton/infrastructure/common/src/repository.py +12 -0
  46. skeleton/infrastructure/common/src/utils.py +26 -0
  47. skeleton/infrastructure/gateway/__init__.py +0 -0
  48. skeleton/infrastructure/gateway/settings/proxy.json +24 -0
  49. skeleton/infrastructure/gateway/settings/services.json +10 -0
  50. skeleton/infrastructure/gateway/src/__init__.py +0 -0
  51. skeleton/infrastructure/gateway/src/app.py +98 -0
  52. skeleton/infrastructure/gateway/src/client.py +68 -0
  53. skeleton/infrastructure/gateway/src/config.py +29 -0
  54. skeleton/infrastructure/gateway/src/context.py +31 -0
  55. skeleton/infrastructure/gateway/src/headers.py +17 -0
  56. skeleton/infrastructure/gateway/src/lifespan.py +17 -0
  57. skeleton/infrastructure/gateway/src/loader.py +113 -0
  58. skeleton/infrastructure/gateway/src/logging.py +37 -0
  59. skeleton/infrastructure/gateway/src/main.py +139 -0
  60. skeleton/infrastructure/gateway/src/parser.py +59 -0
  61. skeleton/infrastructure/gateway/src/registry.py +30 -0
  62. skeleton/infrastructure/gateway/src/reverseproxy.py +54 -0
  63. skeleton/infrastructure/gateway/src/router.py +74 -0
  64. skeleton/infrastructure/gateway/src/schemas.py +21 -0
  65. skeleton/infrastructure/gateway/src/service.py +123 -0
  66. skeleton/infrastructure/gateway/src/utils.py +48 -0
  67. skeleton/infrastructure/gateway/src/validation.py +89 -0
  68. skeleton/infrastructure/layout.json +12 -0
  69. skeleton/infrastructure/runners/__init__.py +0 -0
  70. skeleton/infrastructure/runners/src/__init__.py +0 -0
  71. skeleton/infrastructure/runners/src/actions.py +4 -0
  72. skeleton/infrastructure/runners/src/browser.py +18 -0
  73. skeleton/infrastructure/runners/src/cmd.py +7 -0
  74. skeleton/infrastructure/runners/src/command.py +7 -0
  75. skeleton/infrastructure/runners/src/frontend.py +35 -0
  76. skeleton/infrastructure/runners/src/gateway.py +104 -0
  77. skeleton/infrastructure/runners/src/hook.py +53 -0
  78. skeleton/infrastructure/runners/src/main.py +78 -0
  79. skeleton/infrastructure/runners/src/packages.py +35 -0
  80. skeleton/infrastructure/runners/src/parser.py +84 -0
  81. skeleton/infrastructure/runners/src/process.py +99 -0
  82. skeleton/infrastructure/runners/src/services.py +16 -0
  83. skeleton/infrastructure/runners/src/utils.py +6 -0
  84. skeleton/infrastructure/runners/src/validation.py +28 -0
  85. skeleton/packages/.skeleton/skeleton-cli-1.0.0.tgz +0 -0
  86. skeleton/packages/.skeleton/skeleton-webpack-1.0.0.tgz +0 -0
  87. skeleton/root.py +234 -0
  88. skeleton_framework-0.1.0.dist-info/METADATA +440 -0
  89. skeleton_framework-0.1.0.dist-info/RECORD +93 -0
  90. skeleton_framework-0.1.0.dist-info/WHEEL +5 -0
  91. skeleton_framework-0.1.0.dist-info/entry_points.txt +2 -0
  92. skeleton_framework-0.1.0.dist-info/licenses/LICENSE +146 -0
  93. skeleton_framework-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,68 @@
1
+ import httpx
2
+ from fastapi import Request, Response
3
+
4
+ from starlette.datastructures import Headers
5
+
6
+ from skeleton.infrastructure.gateway.src.schemas import UpstreamRequest
7
+ from skeleton.infrastructure.gateway.src.lifespan import HTTPAsyncClientManager
8
+ from skeleton.infrastructure.gateway.src.headers import HOP_BY_HOP_HEADERS
9
+
10
+ # talks directly to client &
11
+ # sends AsyncioClient.request()
12
+ class ProxyClient:
13
+
14
+ def __init__(self, manager: HTTPAsyncClientManager):
15
+ self.manager = manager
16
+
17
+ @property
18
+ def client(self):
19
+ return self.manager.client
20
+
21
+ def filter_hop_by_hop_headers(self, h: Headers) -> dict:
22
+
23
+ headers = dict(h)
24
+
25
+ # httpx auto regenerates host header from
26
+ headers.pop("host", None)
27
+
28
+ connection = headers.get("connection")
29
+
30
+ if connection:
31
+ for header in connection.split(","):
32
+ headers.pop(header.strip().lower(), None)
33
+
34
+ for header in HOP_BY_HOP_HEADERS:
35
+ headers.pop(header.lower(), None)
36
+
37
+ return headers
38
+
39
+ async def request(self, clientRequest: UpstreamRequest):
40
+
41
+ request: Request = clientRequest.request
42
+
43
+ headers = self.filter_hop_by_hop_headers(
44
+ clientRequest.request.headers
45
+ )
46
+
47
+ # host regenerated by upstream target!
48
+ url = httpx.URL(clientRequest.target)
49
+ headers["host"] = (
50
+ f"{url.host}:{url.port}"
51
+ if url.port else url.host
52
+ )
53
+ # print(json.dumps(headers, indent=4))
54
+
55
+ # faster, once connected!
56
+ upstream_response = await self.client.request(
57
+ method=request.method,
58
+ url=clientRequest.target,
59
+ headers=headers,
60
+ # stream body in chunks!
61
+ content=await request.body()
62
+ )
63
+
64
+ return Response(
65
+ content=upstream_response.content,
66
+ status_code=upstream_response.status_code,
67
+ headers=dict(upstream_response.headers)
68
+ )
@@ -0,0 +1,29 @@
1
+ from pathlib import Path
2
+ from dataclasses import dataclass
3
+
4
+ @dataclass(frozen=True, kw_only=True)
5
+ class ProcessConfig:
6
+ module: str
7
+
8
+ @dataclass(frozen=True, kw_only=True)
9
+ class NetworkConfig:
10
+ host: str
11
+ port: int
12
+
13
+ @dataclass(frozen=True, kw_only=True)
14
+ class WatchConfig:
15
+ enabled: bool
16
+ extensions: tuple[str, ...]
17
+
18
+ @dataclass(frozen=True, kw_only=True)
19
+ class ServiceConfig:
20
+
21
+ id: str # backend (ALWAYS!) [FOR NOW!]
22
+ name: str # app => actual FastAPI app name
23
+ root: Path # resolving module paths, file paths, etc.
24
+
25
+ # service specs
26
+ network: NetworkConfig
27
+ process: ProcessConfig
28
+ watch: WatchConfig
29
+
@@ -0,0 +1,31 @@
1
+ from pathlib import Path
2
+ from dataclasses import dataclass
3
+
4
+ @dataclass(frozen=True, kw_only=True)
5
+ class ProcessContext:
6
+ module: str
7
+ exe: Path
8
+ cwd: Path
9
+
10
+ @dataclass(frozen=True, kw_only=True)
11
+ class NetworkContext:
12
+ host: str
13
+ port: int
14
+
15
+ @dataclass(frozen=True, kw_only=True)
16
+ class WatchContext:
17
+ enabled: bool
18
+ directory: Path
19
+ extensions: tuple[str, ...]
20
+
21
+ @dataclass(frozen=True, kw_only=True)
22
+ class ServiceContext:
23
+
24
+ id: str # backend (ALWAYS!) [FOR NOW!]
25
+ name: str # app => actual FastAPI app name
26
+ root: Path # resolving module paths, file paths, etc.
27
+
28
+ network: NetworkContext
29
+ process: ProcessContext
30
+ watch: WatchContext
31
+
@@ -0,0 +1,17 @@
1
+ # HTTP protocol:
2
+ #
3
+ # https://en.wikipedia.org/wiki/HTTP
4
+ #
5
+ # See RFC 7230:
6
+ #
7
+ # proxy servers should not forward the following header data:
8
+ HOP_BY_HOP_HEADERS = {
9
+ "connection",
10
+ "keep-alive",
11
+ "proxy-authenticate",
12
+ "proxy-authorization",
13
+ "te",
14
+ "trailers",
15
+ "transfer-encoding",
16
+ "upgrade"
17
+ }
@@ -0,0 +1,17 @@
1
+ import httpx
2
+
3
+ class HTTPAsyncClientManager:
4
+
5
+ def __init__(self, config: dict) -> None:
6
+ self.config = config
7
+ self.client: httpx.AsyncClient | None = None
8
+
9
+ async def startup(self):
10
+ self.client = httpx.AsyncClient(
11
+ timeout=httpx.Timeout(self.config["defaultTimeout"]),
12
+ follow_redirects=self.config["followRedirects"]
13
+ )
14
+
15
+ async def shutdown(self):
16
+ if self.client:
17
+ await self.client.aclose()
@@ -0,0 +1,113 @@
1
+ from pathlib import Path
2
+
3
+ from skeleton.infrastructure.common.src.context import RuntimeContext
4
+
5
+ from skeleton.infrastructure.gateway.src.utils import normalize_extensions
6
+
7
+ from skeleton.infrastructure.common.src.utils import (
8
+ path_to_module, resolve_executable_path
9
+ )
10
+
11
+ from skeleton.infrastructure.gateway.src.context import (
12
+ NetworkContext, ProcessContext,
13
+ ServiceContext, WatchContext
14
+ )
15
+
16
+ from skeleton.infrastructure.gateway.src.config import (
17
+ NetworkConfig, ProcessConfig,
18
+ ServiceConfig, WatchConfig
19
+ )
20
+
21
+ def load_service_configs(
22
+ context: RuntimeContext
23
+ ) -> list[ServiceConfig]:
24
+
25
+ configs: list[ServiceConfig] = []
26
+
27
+ servers = context.config.servers["servers"]
28
+ services = context.config.services["services"]
29
+
30
+ # use services to get the relevant server for that service:
31
+ for name, service in services.items():
32
+
33
+ networkConfig = NetworkConfig(
34
+ host=servers[name]["host"],
35
+ port=servers[name]["port"],
36
+ )
37
+
38
+ watchConfig = WatchConfig(
39
+ enabled=service["reload"],
40
+ extensions=service["ext"],
41
+ )
42
+
43
+ if name == "backend":
44
+
45
+ service_root = context.layout.backend
46
+
47
+ module_path = service_root / "src" / "main.py"
48
+
49
+ module = path_to_module(module_path.relative_to(service_root))
50
+
51
+ else:
52
+
53
+ service_root = context.root / service["root"]
54
+ module = service["module"]
55
+
56
+ processConfig = ProcessConfig(
57
+ module=module
58
+ )
59
+
60
+ configs.append(
61
+ ServiceConfig(
62
+ root=service_root,
63
+ id=name,
64
+ name=service["name"],
65
+ network=networkConfig,
66
+ watch=watchConfig,
67
+ process=processConfig,
68
+ )
69
+ )
70
+
71
+ return configs
72
+
73
+ # build the immutable/frozen
74
+ # service configuration objects
75
+ def build_service_contexts(
76
+ configs: list[ServiceConfig]
77
+ ) -> list[ServiceContext]:
78
+
79
+ contexts: list[ServiceContext] = []
80
+
81
+ for config in configs:
82
+
83
+ process = ProcessContext(
84
+ module=config.process.module,
85
+ exe=resolve_executable_path(config.root),
86
+ cwd=Path(config.root),
87
+ )
88
+
89
+ network = NetworkContext(
90
+ host=config.network.host,
91
+ port=config.network.port,
92
+ )
93
+
94
+ watch = WatchContext(
95
+ enabled=config.watch.enabled,
96
+ directory=config.root,
97
+ extensions=normalize_extensions(
98
+ config.watch.extensions
99
+ ),
100
+ )
101
+
102
+ contexts.append(
103
+ ServiceContext(
104
+ id=config.id,
105
+ name=config.name,
106
+ root=config.root,
107
+ process=process,
108
+ network=network,
109
+ watch=watch,
110
+ )
111
+ )
112
+
113
+ return contexts
@@ -0,0 +1,37 @@
1
+ import logging
2
+ import uvicorn
3
+
4
+ from copy import deepcopy
5
+
6
+ class AccessLogFilter(logging.Filter):
7
+
8
+ def filter(
9
+ self,
10
+ record: logging.LogRecord
11
+ ) -> bool:
12
+
13
+ return (
14
+ "GET /.well-known/appspecific/com.chrome.devtools.json "
15
+ not in record.getMessage()
16
+ )
17
+
18
+ def build_log_config() -> dict:
19
+
20
+ config = deepcopy(
21
+ uvicorn.config.LOGGING_CONFIG
22
+ )
23
+
24
+ config["filters"] = {
25
+ "skeleton_access": {
26
+ "()": (
27
+ "skeleton.infrastructure.gateway.src.logging."
28
+ "AccessLogFilter"
29
+ ),
30
+ },
31
+ }
32
+
33
+ config["handlers"]["access"]["filters"] = [
34
+ "skeleton_access",
35
+ ]
36
+
37
+ return config
@@ -0,0 +1,139 @@
1
+ import sys, asyncio, uvicorn
2
+
3
+ from pathlib import Path
4
+
5
+ from skeleton.infrastructure.common.src.constants import Bootstrap
6
+ from skeleton.infrastructure.common.src.context import RuntimeContext
7
+ from skeleton.infrastructure.common.src.config import load_runtime_config
8
+ from skeleton.infrastructure.common.src.layout import build_layout
9
+ from skeleton.infrastructure.common.src.env import build_env
10
+
11
+ from skeleton.infrastructure.gateway.src.config import ServiceConfig
12
+ from skeleton.infrastructure.gateway.src.context import ServiceContext
13
+ from skeleton.infrastructure.gateway.src.service import Service
14
+ from skeleton.infrastructure.gateway.src.registry import Registry
15
+
16
+ from skeleton.infrastructure.gateway.src.logging import build_log_config
17
+
18
+ from skeleton.infrastructure.gateway.src.app import create_app
19
+ from skeleton.infrastructure.gateway.src.parser import parse
20
+
21
+ from skeleton.infrastructure.gateway.src.validation import (
22
+ validate_service_configs,
23
+ validate_service_contexts
24
+ )
25
+
26
+ from skeleton.infrastructure.gateway.src.loader import (
27
+ build_service_contexts, load_service_configs
28
+ )
29
+
30
+ # service orchestration!
31
+ async def start(context: RuntimeContext) -> None:
32
+
33
+ # pass in RuntimeContext
34
+ registry = Registry()
35
+
36
+ print("INFO: Starting reverse proxy watchfile services ...")
37
+
38
+ # load & build the config objects
39
+ # configs = load_service_config(ROOT)
40
+ serviceConfigs: list[ServiceConfig] = load_service_configs(context)
41
+ validConfigs = validate_service_configs(serviceConfigs)
42
+
43
+ if not validConfigs:
44
+ exit(1)
45
+
46
+ serviceContexts: list[ServiceContext] = build_service_contexts(serviceConfigs)
47
+ validContexts = validate_service_contexts(serviceContexts)
48
+
49
+ if not validContexts:
50
+ exit(1)
51
+
52
+ # log config data for visual output!
53
+ # for name, config in configs.items():
54
+ # print(f"{name}:", config.to_json())
55
+
56
+ # register config objects as services!
57
+ for service in serviceContexts:
58
+ registry.regsiter(Service(service))
59
+
60
+ try:
61
+
62
+ # start all services
63
+ await registry.start_all()
64
+ app = create_app(context)
65
+
66
+ servers = context.config.servers["servers"]
67
+ gateway = servers["gateway"]
68
+
69
+ # create the uvicorn server!
70
+ server = uvicorn.Server(
71
+ uvicorn.Config(
72
+ app,
73
+ host=gateway["host"],
74
+ port=gateway["port"],
75
+ log_config=build_log_config(),
76
+ )
77
+ )
78
+
79
+ # start the server, then ...
80
+ # await console interrupt!
81
+ await server.serve()
82
+
83
+ except asyncio.CancelledError:
84
+ print("INFO: Stopping reverse proxy watchfile services ...")
85
+
86
+ finally:
87
+ # shutdown all services!
88
+ await registry.stop_all()
89
+
90
+ # receive subprocess command line flags passed like:
91
+ # ['--root', 'C:\\Users\\Lambda\\Desktop\\skeleton', '--watch', '--open']
92
+ if __name__ == "__main__":
93
+
94
+ args = sys.argv[1:]
95
+ raw = parse(args)
96
+
97
+ PROJECT_ROOT = Path(raw["root"]).resolve()
98
+ FRAMEWORK_ROOT = Path(__file__).resolve().parents[4]
99
+ MODE = raw["mode"]
100
+
101
+ options: dict[str, str | bool] = {
102
+ "root": str(PROJECT_ROOT),
103
+ "mode": MODE,
104
+ "watch": raw["watch"],
105
+ "open": raw["open"],
106
+ }
107
+
108
+ layout = build_layout(
109
+ PROJECT_ROOT / Bootstrap.LAYOUT,
110
+ project_root=PROJECT_ROOT,
111
+ framework_root=FRAMEWORK_ROOT,
112
+ )
113
+
114
+ # config = load_runtime_config(
115
+ # layout.common / Bootstrap.CONFIG_FILE_EXT,
116
+ # FRAMEWORK_ROOT,
117
+ # layout,
118
+ # )
119
+
120
+ config = load_runtime_config(
121
+ layout.common / Bootstrap.CONFIG_FILE_EXT,
122
+ PROJECT_ROOT,
123
+ FRAMEWORK_ROOT,
124
+ layout,
125
+ )
126
+
127
+ env = build_env(PROJECT_ROOT, MODE)
128
+
129
+ context = RuntimeContext(
130
+ root=PROJECT_ROOT,
131
+ mode=MODE,
132
+ args=args,
133
+ env=env,
134
+ layout=layout,
135
+ options=options,
136
+ config=config,
137
+ )
138
+
139
+ asyncio.run(start(context))
@@ -0,0 +1,59 @@
1
+ # command line input received from
2
+ # subprocess call may look like:
3
+ # --mode prod --watch --open
4
+
5
+ # arguments are thus:
6
+ # ["--mode", "prod", "--watch", "--open"]
7
+ def parse(argv: list[str]) -> dict:
8
+
9
+ options: dict[str, str | bool] = {
10
+ "mode": "dev",
11
+ "watch": False,
12
+ "open": False,
13
+ } # dev/prod
14
+
15
+ i = 0
16
+
17
+ while i < len(argv):
18
+
19
+ arg = argv[i]
20
+
21
+ if arg == "--mode":
22
+
23
+ if i + 1 >= len(argv):
24
+ raise ValueError("--mode requires a values to be specified")
25
+
26
+ mode = argv[i + 1]
27
+
28
+ if mode not in ["dev", "prod"]:
29
+ raise ValueError(f"invalid mode: {mode}")
30
+
31
+ options["mode"] = mode
32
+
33
+ i += 2
34
+ continue
35
+
36
+ if arg == "--root":
37
+
38
+ if i + 1 >= len(argv):
39
+ raise ValueError("--root requires a values to be specified")
40
+
41
+ root = argv[i + 1]
42
+ options["root"] = root
43
+
44
+ i += 2
45
+ continue
46
+
47
+ if arg == "--watch":
48
+ options["watch"] = True
49
+ i += 1
50
+ continue
51
+
52
+ if arg == "--open":
53
+ options["open"] = True
54
+ i += 1
55
+ continue
56
+
57
+ raise ValueError(f"Unknown option: {arg}")
58
+
59
+ return options
@@ -0,0 +1,30 @@
1
+ import asyncio
2
+
3
+ from skeleton.infrastructure.gateway.src.service import Service
4
+
5
+ # service config manager!
6
+ class Registry:
7
+
8
+ def __init__(self) -> None:
9
+ self.services: dict[str, Service] = {}
10
+
11
+ def regsiter(self, service: Service) -> None:
12
+ self.services[service.config.id] = service
13
+
14
+ async def start_all(self) -> None:
15
+ await asyncio.gather(
16
+ *[
17
+ service.run()
18
+ for service
19
+ in self.services.values()
20
+ ]
21
+ )
22
+
23
+ async def stop_all(self) -> None:
24
+
25
+ print("INFO: Gracefully shutting down all services ...")
26
+ for service in self.services.values():
27
+ await service.shutdown()
28
+
29
+ def get(self, name) -> Service:
30
+ return self.services[name]
@@ -0,0 +1,54 @@
1
+ from fastapi import Request
2
+ from urllib.parse import urljoin
3
+
4
+ from skeleton.infrastructure.gateway.src.client import ProxyClient
5
+ from skeleton.infrastructure.gateway.src.router import ProxyRouter
6
+ from skeleton.infrastructure.gateway.src.schemas import UpstreamRequest
7
+
8
+ from pathlib import PurePosixPath
9
+
10
+ # coming from:
11
+ # http://127.0.0.1:8000/api/some/path
12
+ # resolves to:
13
+ # http://127.0.0.1:8080/some/path
14
+ # send request to Backend!
15
+
16
+ # coming from:
17
+ # http://127.0.0.1:8000/some/path
18
+ # resolves to:
19
+ # http://127.0.0.1:3000/some/path
20
+ # send request to Frontend!
21
+
22
+ def is_asset(path: str) -> bool:
23
+ return "." in PurePosixPath(path).name
24
+
25
+ class ReverseProxy:
26
+
27
+ def __init__(self, router: ProxyRouter, client: ProxyClient):
28
+ self.router = router
29
+ self.client = client
30
+
31
+ async def handle_http_request(self, request: Request):
32
+
33
+ path = request.url.path
34
+ upstream = self.router.resolve(path)
35
+
36
+ # SPA Fallback logic!
37
+ if upstream.target == self.router.frontend.target:
38
+ if not is_asset(path):
39
+ path = f"/{self.router.index}"
40
+
41
+ # build the target url
42
+ target = urljoin(upstream.target, upstream.path)
43
+
44
+ # if request has query params
45
+ # include them for client.request!
46
+ if request.url.query:
47
+ target += f"?{request.url.query}"
48
+
49
+ return await self.client.request(
50
+ UpstreamRequest(
51
+ request=request,
52
+ target=target
53
+ )
54
+ )
@@ -0,0 +1,74 @@
1
+ from skeleton.infrastructure.common.src.context import RuntimeContext
2
+ from skeleton.infrastructure.gateway.src.schemas import RouteContext, UpstreamContext
3
+
4
+ class ProxyRouter:
5
+
6
+ def __init__(self, context: RuntimeContext):
7
+
8
+ schemas = context.config.schemas["schemas"]
9
+ http = schemas["http"]
10
+ ws = schemas["ws"]
11
+
12
+ # print(http)
13
+ # print(ws)
14
+
15
+ # index.html file name configurable by:
16
+ # skeleton/app/frontend/webpack/config.json
17
+ self.index = context.config.frontend["entry"]["index"]
18
+
19
+ backend = context.config.servers["servers"]["backend"]
20
+ frontend = context.config.servers["servers"]["frontend"]
21
+
22
+ self.backend = RouteContext(
23
+ prefix=http["backend"]["prefix"],
24
+ target=f"http://{backend['host']}:{backend['port']}",
25
+ strip=http["backend"]["strip"],
26
+ protocol="http",
27
+ )
28
+
29
+ self.frontend = RouteContext(
30
+ prefix=http["frontend"]["prefix"],
31
+ target=f"http://{frontend['host']}:{frontend['port']}",
32
+ strip=http["frontend"]["strip"],
33
+ protocol="http",
34
+ )
35
+
36
+ self.websock = RouteContext(
37
+ prefix=ws["backend"]["prefix"],
38
+ target=f"http://{backend['host']}:{backend['port']}",
39
+ strip=http["backend"]["strip"],
40
+ protocol="ws",
41
+ )
42
+
43
+ # decide where to forward/redirect
44
+ def resolve(self, path: str) -> UpstreamContext:
45
+
46
+ if path.startswith(self.backend.prefix):
47
+
48
+ if self.backend.strip:
49
+ path = path.removeprefix(self.backend.prefix)
50
+
51
+ return UpstreamContext(
52
+ target=self.backend.target,
53
+ path=path,
54
+ protocol=self.backend.protocol,
55
+ )
56
+
57
+ if path.startswith(self.websock.prefix):
58
+
59
+ if self.websock.strip:
60
+ path = path.removeprefix(self.websock.prefix)
61
+
62
+ return UpstreamContext(
63
+ target=self.websock.target,
64
+ path=path,
65
+ protocol=self.websock.protocol,
66
+ )
67
+
68
+ # Every other shape routes to frontend SPA!
69
+ return UpstreamContext(
70
+ target=self.frontend.target,
71
+ path=path,
72
+ protocol=self.frontend.protocol,
73
+ )
74
+
@@ -0,0 +1,21 @@
1
+ from dataclasses import dataclass
2
+
3
+ from fastapi import Request
4
+
5
+ @dataclass(frozen=True, kw_only=True)
6
+ class RouteContext:
7
+ prefix: str # /api or /ws or /
8
+ target: str # frontend/backend
9
+ strip: bool # weather to strip prefixes
10
+ protocol: str # http or ws
11
+
12
+ @dataclass(frozen=True, kw_only=True)
13
+ class UpstreamContext:
14
+ path: str # composed path/...
15
+ target: str # resolved
16
+ protocol: str # http or ws
17
+
18
+ @dataclass(frozen=True, kw_only=True)
19
+ class UpstreamRequest:
20
+ target: str
21
+ request: Request