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.
- skeleton/__init__.py +0 -0
- skeleton/cli.py +151 -0
- skeleton/client.py +269 -0
- skeleton/infrastructure/__init__.py +1 -0
- skeleton/infrastructure/bootstrap/__init__.py +0 -0
- skeleton/infrastructure/bootstrap/src/__init__.py +0 -0
- skeleton/infrastructure/bootstrap/src/commands/__init__.py +0 -0
- skeleton/infrastructure/bootstrap/src/commands/client/__init__.py +0 -0
- skeleton/infrastructure/bootstrap/src/commands/client/init.py +486 -0
- skeleton/infrastructure/bootstrap/src/commands/client/install.py +113 -0
- skeleton/infrastructure/bootstrap/src/commands/dev/__init__.py +0 -0
- skeleton/infrastructure/bootstrap/src/commands/dev/build.py +80 -0
- skeleton/infrastructure/bootstrap/src/commands/dev/init.py +80 -0
- skeleton/infrastructure/bootstrap/src/commands/dev/install.py +168 -0
- skeleton/infrastructure/bootstrap/src/commands/dev/pack.py +55 -0
- skeleton/infrastructure/bootstrap/src/config.py +54 -0
- skeleton/infrastructure/bootstrap/src/copy.py +56 -0
- skeleton/infrastructure/bootstrap/src/load.py +221 -0
- skeleton/infrastructure/bootstrap/src/templates/app/backend/requirements.txt +1 -0
- skeleton/infrastructure/bootstrap/src/templates/app/backend/routes.json +10 -0
- skeleton/infrastructure/bootstrap/src/templates/app/backend/src/__init__.py +0 -0
- skeleton/infrastructure/bootstrap/src/templates/app/backend/src/main.py +35 -0
- skeleton/infrastructure/bootstrap/src/templates/app/frontend/package.json +21 -0
- skeleton/infrastructure/bootstrap/src/templates/app/frontend/skeleton-config.json +29 -0
- skeleton/infrastructure/bootstrap/src/templates/app/frontend/src/index.html +16 -0
- skeleton/infrastructure/bootstrap/src/templates/app/frontend/src/main.tsx +15 -0
- skeleton/infrastructure/bootstrap/src/templates/app/frontend/src/static/css/styles.css +28 -0
- skeleton/infrastructure/bootstrap/src/templates/app/frontend/src/static/img/skeleton.png +0 -0
- skeleton/infrastructure/bootstrap/src/templates/app/frontend/src/static/js/script.js +1 -0
- skeleton/infrastructure/bootstrap/src/templates/app/frontend/tsconfig.json +90 -0
- skeleton/infrastructure/bootstrap/src/templates/config/.gitignore +21 -0
- skeleton/infrastructure/bootstrap/src/templates/config/skeleton-dependencies.json +21 -0
- skeleton/infrastructure/bootstrap/src/templates/config/skeleton-layout.json +11 -0
- skeleton/infrastructure/bootstrap/src/templates/config/skeleton-schemas.json +20 -0
- skeleton/infrastructure/bootstrap/src/templates/config/skeleton-servers.json +16 -0
- skeleton/infrastructure/common/__init__.py +1 -0
- skeleton/infrastructure/common/config.json +8 -0
- skeleton/infrastructure/common/src/__init__.py +0 -0
- skeleton/infrastructure/common/src/config.py +37 -0
- skeleton/infrastructure/common/src/constants.py +4 -0
- skeleton/infrastructure/common/src/context.py +15 -0
- skeleton/infrastructure/common/src/env.py +12 -0
- skeleton/infrastructure/common/src/layout.py +68 -0
- skeleton/infrastructure/common/src/loader.py +8 -0
- skeleton/infrastructure/common/src/repository.py +12 -0
- skeleton/infrastructure/common/src/utils.py +26 -0
- skeleton/infrastructure/gateway/__init__.py +0 -0
- skeleton/infrastructure/gateway/settings/proxy.json +24 -0
- skeleton/infrastructure/gateway/settings/services.json +10 -0
- skeleton/infrastructure/gateway/src/__init__.py +0 -0
- skeleton/infrastructure/gateway/src/app.py +98 -0
- skeleton/infrastructure/gateway/src/client.py +68 -0
- skeleton/infrastructure/gateway/src/config.py +29 -0
- skeleton/infrastructure/gateway/src/context.py +31 -0
- skeleton/infrastructure/gateway/src/headers.py +17 -0
- skeleton/infrastructure/gateway/src/lifespan.py +17 -0
- skeleton/infrastructure/gateway/src/loader.py +113 -0
- skeleton/infrastructure/gateway/src/logging.py +37 -0
- skeleton/infrastructure/gateway/src/main.py +139 -0
- skeleton/infrastructure/gateway/src/parser.py +59 -0
- skeleton/infrastructure/gateway/src/registry.py +30 -0
- skeleton/infrastructure/gateway/src/reverseproxy.py +54 -0
- skeleton/infrastructure/gateway/src/router.py +74 -0
- skeleton/infrastructure/gateway/src/schemas.py +21 -0
- skeleton/infrastructure/gateway/src/service.py +123 -0
- skeleton/infrastructure/gateway/src/utils.py +48 -0
- skeleton/infrastructure/gateway/src/validation.py +89 -0
- skeleton/infrastructure/layout.json +12 -0
- skeleton/infrastructure/runners/__init__.py +0 -0
- skeleton/infrastructure/runners/src/__init__.py +0 -0
- skeleton/infrastructure/runners/src/actions.py +4 -0
- skeleton/infrastructure/runners/src/browser.py +18 -0
- skeleton/infrastructure/runners/src/cmd.py +7 -0
- skeleton/infrastructure/runners/src/command.py +7 -0
- skeleton/infrastructure/runners/src/frontend.py +35 -0
- skeleton/infrastructure/runners/src/gateway.py +104 -0
- skeleton/infrastructure/runners/src/hook.py +53 -0
- skeleton/infrastructure/runners/src/main.py +78 -0
- skeleton/infrastructure/runners/src/packages.py +35 -0
- skeleton/infrastructure/runners/src/parser.py +84 -0
- skeleton/infrastructure/runners/src/process.py +99 -0
- skeleton/infrastructure/runners/src/services.py +16 -0
- skeleton/infrastructure/runners/src/utils.py +6 -0
- skeleton/infrastructure/runners/src/validation.py +28 -0
- skeleton/packages/.skeleton/skeleton-cli-1.0.0.tgz +0 -0
- skeleton/packages/.skeleton/skeleton-webpack-1.0.0.tgz +0 -0
- skeleton/root.py +234 -0
- skeleton_framework-0.1.0.dist-info/METADATA +440 -0
- skeleton_framework-0.1.0.dist-info/RECORD +93 -0
- skeleton_framework-0.1.0.dist-info/WHEEL +5 -0
- skeleton_framework-0.1.0.dist-info/entry_points.txt +2 -0
- skeleton_framework-0.1.0.dist-info/licenses/LICENSE +146 -0
- 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
|