service-forge 0.1.18__py3-none-any.whl → 0.1.39__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.
Potentially problematic release.
This version of service-forge might be problematic. Click here for more details.
- service_forge/__init__.py +0 -0
- service_forge/api/deprecated_websocket_api.py +91 -33
- service_forge/api/deprecated_websocket_manager.py +70 -53
- service_forge/api/http_api.py +205 -55
- service_forge/api/kafka_api.py +113 -25
- service_forge/api/routers/meta_api/meta_api_router.py +57 -0
- service_forge/api/routers/service/service_router.py +42 -6
- service_forge/api/routers/trace/trace_router.py +326 -0
- service_forge/api/routers/websocket/websocket_router.py +69 -1
- service_forge/api/service_studio.py +9 -0
- service_forge/db/database.py +17 -0
- service_forge/execution_context.py +106 -0
- service_forge/frontend/static/assets/CreateNewNodeDialog-DkrEMxSH.js +1 -0
- service_forge/frontend/static/assets/CreateNewNodeDialog-DwFcBiGp.css +1 -0
- service_forge/frontend/static/assets/EditorSidePanel-BNVms9Fq.css +1 -0
- service_forge/frontend/static/assets/EditorSidePanel-DZbB3ILL.js +1 -0
- service_forge/frontend/static/assets/FeedbackPanel-CC8HX7Yo.js +1 -0
- service_forge/frontend/static/assets/FeedbackPanel-ClgniIVk.css +1 -0
- service_forge/frontend/static/assets/FormattedCodeViewer.vue_vue_type_script_setup_true_lang-BNuI1NCs.js +1 -0
- service_forge/frontend/static/assets/NodeDetailWrapper-BqFFM7-r.js +1 -0
- service_forge/frontend/static/assets/NodeDetailWrapper-pZBxv3J0.css +1 -0
- service_forge/frontend/static/assets/TestRunningDialog-D0GrCoYs.js +1 -0
- service_forge/frontend/static/assets/TestRunningDialog-dhXOsPgH.css +1 -0
- service_forge/frontend/static/assets/TracePanelWrapper-B9zvDSc_.js +1 -0
- service_forge/frontend/static/assets/TracePanelWrapper-BiednCrq.css +1 -0
- service_forge/frontend/static/assets/WorkflowEditor-CcaGGbko.js +3 -0
- service_forge/frontend/static/assets/WorkflowEditor-CmasOOYK.css +1 -0
- service_forge/frontend/static/assets/WorkflowList-Copuwi-a.css +1 -0
- service_forge/frontend/static/assets/WorkflowList-LrRJ7B7h.js +1 -0
- service_forge/frontend/static/assets/WorkflowStudio-CthjgII2.css +1 -0
- service_forge/frontend/static/assets/WorkflowStudio-FCyhGD4y.js +2 -0
- service_forge/frontend/static/assets/api-BDer3rj7.css +1 -0
- service_forge/frontend/static/assets/api-DyiqpKJK.js +1 -0
- service_forge/frontend/static/assets/code-editor-DBSql_sc.js +12 -0
- service_forge/frontend/static/assets/el-collapse-item-D4LG0FJ0.css +1 -0
- service_forge/frontend/static/assets/el-empty-D4ZqTl4F.css +1 -0
- service_forge/frontend/static/assets/el-form-item-BWkJzdQ_.css +1 -0
- service_forge/frontend/static/assets/el-input-D6B3r8CH.css +1 -0
- service_forge/frontend/static/assets/el-select-B0XIb2QK.css +1 -0
- service_forge/frontend/static/assets/el-tag-DljBBxJR.css +1 -0
- service_forge/frontend/static/assets/element-ui-D3x2y3TA.js +12 -0
- service_forge/frontend/static/assets/elkjs-Dm5QV7uy.js +24 -0
- service_forge/frontend/static/assets/highlightjs-D4ATuRwX.js +3 -0
- service_forge/frontend/static/assets/index-BMvodlwc.js +2 -0
- service_forge/frontend/static/assets/index-CjSe8i2q.css +1 -0
- service_forge/frontend/static/assets/js-yaml-yTPt38rv.js +32 -0
- service_forge/frontend/static/assets/time-DKCKV6Ug.js +1 -0
- service_forge/frontend/static/assets/ui-components-DQ7-U3pr.js +1 -0
- service_forge/frontend/static/assets/vue-core-DL-LgTX0.js +1 -0
- service_forge/frontend/static/assets/vue-flow-Dn7R8GPr.js +39 -0
- service_forge/frontend/static/index.html +16 -0
- service_forge/frontend/static/vite.svg +1 -0
- service_forge/model/meta_api/__init__.py +0 -0
- service_forge/model/meta_api/schema.py +29 -0
- service_forge/model/trace.py +82 -0
- service_forge/service.py +39 -11
- service_forge/service_config.py +14 -0
- service_forge/sft/cli.py +39 -0
- service_forge/sft/cmd/remote_deploy.py +160 -0
- service_forge/sft/cmd/remote_list_tars.py +111 -0
- service_forge/sft/config/injector.py +54 -7
- service_forge/sft/config/injector_default_files.py +13 -1
- service_forge/sft/config/sf_metadata.py +31 -27
- service_forge/sft/config/sft_config.py +18 -0
- service_forge/sft/util/assert_util.py +0 -1
- service_forge/telemetry.py +66 -0
- service_forge/utils/default_type_converter.py +1 -1
- service_forge/utils/type_converter.py +5 -0
- service_forge/utils/workflow_clone.py +1 -0
- service_forge/workflow/node.py +274 -27
- service_forge/workflow/triggers/fast_api_trigger.py +64 -28
- service_forge/workflow/triggers/websocket_api_trigger.py +66 -38
- service_forge/workflow/workflow.py +140 -37
- service_forge/workflow/workflow_callback.py +27 -4
- service_forge/workflow/workflow_factory.py +14 -0
- {service_forge-0.1.18.dist-info → service_forge-0.1.39.dist-info}/METADATA +4 -1
- service_forge-0.1.39.dist-info/RECORD +134 -0
- service_forge-0.1.18.dist-info/RECORD +0 -83
- {service_forge-0.1.18.dist-info → service_forge-0.1.39.dist-info}/WHEEL +0 -0
- {service_forge-0.1.18.dist-info → service_forge-0.1.39.dist-info}/entry_points.txt +0 -0
service_forge/api/http_api.py
CHANGED
|
@@ -2,25 +2,39 @@ from fastapi import FastAPI
|
|
|
2
2
|
import uvicorn
|
|
3
3
|
from fastapi import APIRouter
|
|
4
4
|
from loguru import logger
|
|
5
|
+
from typing import Optional
|
|
5
6
|
from urllib.parse import urlparse
|
|
6
|
-
from fastapi import HTTPException, Request
|
|
7
|
+
from fastapi import HTTPException, Request, WebSocket, WebSocketException
|
|
7
8
|
from fastapi.middleware.cors import CORSMiddleware
|
|
8
|
-
|
|
9
|
+
|
|
10
|
+
from opentelemetry import context as otel_context_api
|
|
11
|
+
from opentelemetry import trace
|
|
12
|
+
from opentelemetry.propagate import extract
|
|
13
|
+
from opentelemetry.trace import SpanKind
|
|
14
|
+
|
|
15
|
+
from service_forge.api.routers.meta_api.meta_api_router import meta_api_router
|
|
16
|
+
from service_forge.api.routers.trace.trace_router import trace_router
|
|
17
|
+
from service_forge.api.service_studio import studio_static_files
|
|
9
18
|
from service_forge.api.routers.websocket.websocket_router import websocket_router
|
|
10
19
|
from service_forge.api.routers.service.service_router import service_router
|
|
11
20
|
from service_forge.api.routers.feedback.feedback_router import router as feedback_router
|
|
12
21
|
from service_forge.sft.config.sf_metadata import load_metadata
|
|
13
22
|
from service_forge.sft.util.name_util import get_service_url_name
|
|
23
|
+
from service_forge.execution_context import (
|
|
24
|
+
ExecutionContext,
|
|
25
|
+
reset_current_context,
|
|
26
|
+
set_current_context,
|
|
27
|
+
)
|
|
14
28
|
|
|
15
29
|
def is_trusted_origin(origin_host: str, host: str, trusted_root: str = "ring.shiweinan.com") -> bool:
|
|
16
30
|
"""
|
|
17
31
|
Check if the origin host is trusted based on domain matching.
|
|
18
|
-
|
|
32
|
+
|
|
19
33
|
Args:
|
|
20
34
|
origin_host: The hostname from the origin header
|
|
21
35
|
host: The hostname from the host header
|
|
22
36
|
trusted_root: The trusted root domain (can be customized)
|
|
23
|
-
|
|
37
|
+
|
|
24
38
|
Returns:
|
|
25
39
|
bool: True if the origin is trusted, False otherwise
|
|
26
40
|
"""
|
|
@@ -30,12 +44,97 @@ def is_trusted_origin(origin_host: str, host: str, trusted_root: str = "ring.shi
|
|
|
30
44
|
|
|
31
45
|
# Allow same domain, or subdomains under the same trusted root
|
|
32
46
|
return (
|
|
33
|
-
origin_host == host
|
|
34
|
-
origin_host.endswith("." + trusted_root)
|
|
35
|
-
host.endswith("." + trusted_root)
|
|
47
|
+
origin_host == host
|
|
48
|
+
or origin_host.endswith("." + trusted_root)
|
|
49
|
+
or host.endswith("." + trusted_root)
|
|
36
50
|
)
|
|
37
51
|
|
|
38
52
|
|
|
53
|
+
def validate_auth_from_headers(
|
|
54
|
+
headers: dict,
|
|
55
|
+
origin: str | None,
|
|
56
|
+
scheme: str,
|
|
57
|
+
host: str,
|
|
58
|
+
trusted_domain: str = "ring.shiweinan.com",
|
|
59
|
+
) -> tuple[str | None, str | None]:
|
|
60
|
+
"""
|
|
61
|
+
Validate authentication from headers and return user_id and token.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
headers: Dictionary of headers (can be from Request or WebSocket)
|
|
65
|
+
origin: Origin header value
|
|
66
|
+
scheme: URL scheme (http/https/ws/wss)
|
|
67
|
+
host: Host header value
|
|
68
|
+
trusted_domain: Trusted domain for origin validation
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
tuple: (user_id, auth_token) - user_id can be None if not authenticated and not same origin
|
|
72
|
+
"""
|
|
73
|
+
is_same_origin = False
|
|
74
|
+
|
|
75
|
+
logger.debug(f"origin {origin}, host:{host}")
|
|
76
|
+
|
|
77
|
+
if origin and host:
|
|
78
|
+
try:
|
|
79
|
+
parsed_origin = urlparse(origin)
|
|
80
|
+
parsed_host = urlparse(f"{scheme}://{host}")
|
|
81
|
+
is_same_origin = (
|
|
82
|
+
parsed_origin.hostname == parsed_host.hostname
|
|
83
|
+
and parsed_origin.port == parsed_host.port
|
|
84
|
+
and is_trusted_origin(parsed_origin.hostname, parsed_host.hostname, trusted_domain)
|
|
85
|
+
)
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
user_id = headers.get("X-User-ID")
|
|
90
|
+
token = headers.get("X-User-Token")
|
|
91
|
+
|
|
92
|
+
if not is_same_origin:
|
|
93
|
+
# For cross-origin requests, user_id is required
|
|
94
|
+
if not user_id:
|
|
95
|
+
return None, None
|
|
96
|
+
return user_id, token
|
|
97
|
+
else:
|
|
98
|
+
# For same-origin requests, user_id defaults to "0" if not provided
|
|
99
|
+
return user_id if user_id else "0", token
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def authenticate_websocket(
|
|
103
|
+
websocket: WebSocket,
|
|
104
|
+
trusted_domain: str = "ring.shiweinan.com",
|
|
105
|
+
) -> None:
|
|
106
|
+
"""
|
|
107
|
+
Authenticate WebSocket connection and set user_id and auth_token in websocket.state.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
websocket: WebSocket instance
|
|
111
|
+
trusted_domain: Trusted domain for origin validation
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
WebSocketException: If authentication fails
|
|
115
|
+
"""
|
|
116
|
+
if not websocket.url.path.startswith("/api"):
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
origin = websocket.headers.get("origin") or websocket.headers.get("referer")
|
|
120
|
+
scheme = websocket.url.scheme
|
|
121
|
+
host = websocket.headers.get("host", "")
|
|
122
|
+
|
|
123
|
+
user_id, token = validate_auth_from_headers(
|
|
124
|
+
websocket.headers,
|
|
125
|
+
origin,
|
|
126
|
+
scheme,
|
|
127
|
+
host,
|
|
128
|
+
trusted_domain,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if user_id is None:
|
|
132
|
+
raise WebSocketException(code=1008, reason="Unauthorized")
|
|
133
|
+
|
|
134
|
+
websocket.state.user_id = user_id
|
|
135
|
+
websocket.state.auth_token = token
|
|
136
|
+
|
|
137
|
+
|
|
39
138
|
def create_app(
|
|
40
139
|
app: FastAPI | None = None,
|
|
41
140
|
routers: list[APIRouter] | None = None,
|
|
@@ -46,14 +145,14 @@ def create_app(
|
|
|
46
145
|
) -> FastAPI:
|
|
47
146
|
"""
|
|
48
147
|
Create or configure a FastAPI app with common middleware and configuration.
|
|
49
|
-
|
|
148
|
+
|
|
50
149
|
Args:
|
|
51
150
|
app: Optional existing FastAPI instance. If None, creates a new one.
|
|
52
151
|
routers: List of APIRouter instances to include
|
|
53
152
|
cors_origins: List of allowed CORS origins. Defaults to ["*"]
|
|
54
153
|
enable_auth_middleware: Whether to enable authentication middleware
|
|
55
154
|
trusted_domain: Trusted domain for origin validation
|
|
56
|
-
|
|
155
|
+
|
|
57
156
|
Returns:
|
|
58
157
|
FastAPI: Configured FastAPI application instance
|
|
59
158
|
"""
|
|
@@ -63,7 +162,7 @@ def create_app(
|
|
|
63
162
|
# Configure CORS middleware
|
|
64
163
|
if cors_origins is None:
|
|
65
164
|
cors_origins = ["*"]
|
|
66
|
-
|
|
165
|
+
|
|
67
166
|
app.add_middleware(
|
|
68
167
|
CORSMiddleware,
|
|
69
168
|
allow_origins=cors_origins,
|
|
@@ -72,64 +171,111 @@ def create_app(
|
|
|
72
171
|
allow_headers=["*"],
|
|
73
172
|
)
|
|
74
173
|
|
|
174
|
+
@app.middleware("http")
|
|
175
|
+
async def tracing_middleware(request: Request, call_next):
|
|
176
|
+
tracer = trace.get_tracer("service_forge.api.http")
|
|
177
|
+
try:
|
|
178
|
+
parent_context = extract(request.headers)
|
|
179
|
+
except Exception:
|
|
180
|
+
parent_context = otel_context_api.get_current()
|
|
181
|
+
if parent_context is None:
|
|
182
|
+
parent_context = otel_context_api.get_current()
|
|
183
|
+
|
|
184
|
+
span_name = f"HTTP {request.method} {request.url.path}"
|
|
185
|
+
with tracer.start_as_current_span(
|
|
186
|
+
span_name,
|
|
187
|
+
context=parent_context,
|
|
188
|
+
kind=SpanKind.SERVER,
|
|
189
|
+
) as span:
|
|
190
|
+
span.set_attribute("http.method", request.method)
|
|
191
|
+
span.set_attribute("http.target", request.url.path)
|
|
192
|
+
span.set_attribute("http.scheme", request.url.scheme)
|
|
193
|
+
host_header = request.headers.get("host")
|
|
194
|
+
if host_header:
|
|
195
|
+
span.set_attribute("http.host", host_header)
|
|
196
|
+
if request.client:
|
|
197
|
+
span.set_attribute("net.peer.ip", request.client.host)
|
|
198
|
+
span.set_attribute("net.peer.port", request.client.port or 0)
|
|
199
|
+
|
|
200
|
+
execution_context = ExecutionContext(
|
|
201
|
+
trace_context=otel_context_api.get_current(),
|
|
202
|
+
span=span,
|
|
203
|
+
metadata={
|
|
204
|
+
"entrypoint": "http",
|
|
205
|
+
"path": request.url.path,
|
|
206
|
+
"method": request.method,
|
|
207
|
+
},
|
|
208
|
+
)
|
|
209
|
+
token = set_current_context(execution_context)
|
|
210
|
+
request.state.execution_context = execution_context
|
|
211
|
+
try:
|
|
212
|
+
response = await call_next(request)
|
|
213
|
+
finally:
|
|
214
|
+
reset_current_context(token)
|
|
215
|
+
return response
|
|
216
|
+
|
|
75
217
|
# Include routers if provided
|
|
76
218
|
if routers:
|
|
77
219
|
for router in routers:
|
|
78
220
|
app.include_router(router)
|
|
79
221
|
|
|
222
|
+
# Store auth configuration in app.state for WebSocket endpoints to access
|
|
223
|
+
app.state.enable_auth_middleware = enable_auth_middleware
|
|
224
|
+
app.state.trusted_domain = trusted_domain
|
|
225
|
+
|
|
80
226
|
# Always include WebSocket router
|
|
81
227
|
app.include_router(websocket_router)
|
|
82
228
|
|
|
83
229
|
# Include Feedback router
|
|
84
230
|
app.include_router(feedback_router)
|
|
85
231
|
|
|
86
|
-
# Always include Service router
|
|
232
|
+
# Always include Service router, Meta API Router, Trace Router, and Static Files
|
|
87
233
|
app.include_router(service_router)
|
|
88
|
-
|
|
234
|
+
app.include_router(meta_api_router)
|
|
235
|
+
app.include_router(trace_router)
|
|
236
|
+
app.mount('/sdk/studio', studio_static_files)
|
|
237
|
+
|
|
238
|
+
@app.middleware("http")
|
|
239
|
+
async def auth_middleware(request: Request, call_next):
|
|
240
|
+
request.state.user_id = request.headers.get("X-User-ID") or "0"
|
|
241
|
+
request.state.auth_token = request.headers.get("X-User-Token") or ""
|
|
242
|
+
return await call_next(request)
|
|
243
|
+
|
|
244
|
+
|
|
89
245
|
# Add authentication middleware if enabled
|
|
90
|
-
if enable_auth_middleware:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
user_id = headers.get("X-User-ID")
|
|
121
|
-
if not user_id:
|
|
122
|
-
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
123
|
-
|
|
124
|
-
request.state.user_id = user_id
|
|
125
|
-
else:
|
|
126
|
-
# Same-origin requests can skip auth, but still set default user_id
|
|
127
|
-
request.state.user_id = "0" # Can be None or default value as needed
|
|
128
|
-
|
|
129
|
-
return await call_next(request)
|
|
130
|
-
|
|
246
|
+
# if enable_auth_middleware:
|
|
247
|
+
# @app.middleware("http")
|
|
248
|
+
# async def auth_middleware(request: Request, call_next):
|
|
249
|
+
# """
|
|
250
|
+
# Authentication middleware for API routes.
|
|
251
|
+
|
|
252
|
+
# Validates user authentication for /api routes with origin-based
|
|
253
|
+
# trust verification and X-User-ID header validation.
|
|
254
|
+
# """
|
|
255
|
+
# if request.url.path.startswith("/api"):
|
|
256
|
+
# origin = request.headers.get("origin") or request.headers.get("referer")
|
|
257
|
+
# scheme = request.url.scheme
|
|
258
|
+
# host = request.headers.get("host", "")
|
|
259
|
+
|
|
260
|
+
# user_id, token = validate_auth_from_headers(
|
|
261
|
+
# request.headers,
|
|
262
|
+
# origin,
|
|
263
|
+
# scheme,
|
|
264
|
+
# host,
|
|
265
|
+
# trusted_domain,
|
|
266
|
+
# )
|
|
267
|
+
|
|
268
|
+
# if user_id is None:
|
|
269
|
+
# raise HTTPException(status_code=401, detail="Unauthorized")
|
|
270
|
+
|
|
271
|
+
# request.state.user_id = user_id
|
|
272
|
+
# request.state.auth_token = token
|
|
273
|
+
|
|
274
|
+
# return await call_next(request)
|
|
275
|
+
|
|
131
276
|
return app
|
|
132
277
|
|
|
278
|
+
|
|
133
279
|
async def start_fastapi_server(host: str, port: int):
|
|
134
280
|
try:
|
|
135
281
|
config = uvicorn.Config(
|
|
@@ -147,6 +293,10 @@ async def start_fastapi_server(host: str, port: int):
|
|
|
147
293
|
|
|
148
294
|
try:
|
|
149
295
|
metadata = load_metadata("sf-meta.yaml")
|
|
150
|
-
|
|
296
|
+
if metadata.mode == "debug":
|
|
297
|
+
fastapi_app = create_app(enable_auth_middleware=True, root_path=None)
|
|
298
|
+
else:
|
|
299
|
+
fastapi_app = create_app(enable_auth_middleware=metadata.enable_auth_middleware, root_path=f"/api/v1/{get_service_url_name(metadata.name, metadata.version)}")
|
|
151
300
|
except Exception as e:
|
|
152
|
-
|
|
301
|
+
logger.warning(f"Failed to load metadata, using default configuration: {e}")
|
|
302
|
+
fastapi_app = create_app(enable_auth_middleware=True, root_path=None)
|
service_forge/api/kafka_api.py
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from aiokafka import AIOKafkaConsumer, AIOKafkaProducer, ConsumerRecord
|
|
2
|
+
|
|
4
3
|
import asyncio
|
|
5
|
-
import json
|
|
6
4
|
import inspect
|
|
7
|
-
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from aiokafka import AIOKafkaConsumer, AIOKafkaProducer, ConsumerRecord
|
|
8
9
|
from loguru import logger
|
|
10
|
+
from opentelemetry import context as otel_context_api
|
|
11
|
+
from opentelemetry import trace
|
|
12
|
+
from opentelemetry.propagate import extract, inject
|
|
13
|
+
from opentelemetry.trace import SpanKind
|
|
14
|
+
|
|
15
|
+
from service_forge.execution_context import (
|
|
16
|
+
ExecutionContext,
|
|
17
|
+
set_current_context,
|
|
18
|
+
get_current_context,
|
|
19
|
+
reset_current_context,
|
|
20
|
+
)
|
|
21
|
+
|
|
9
22
|
|
|
10
23
|
class KafkaApp:
|
|
11
24
|
def __init__(self, bootstrap_servers: str = None):
|
|
@@ -15,15 +28,21 @@ class KafkaApp:
|
|
|
15
28
|
self._producer: AIOKafkaProducer = None
|
|
16
29
|
self._lock = asyncio.Lock()
|
|
17
30
|
self._running = False
|
|
31
|
+
self._tracer = trace.get_tracer("service_forge.api.kafka")
|
|
18
32
|
|
|
19
33
|
def kafka_input(self, topic: str, data_type: type, group_id: str):
|
|
20
34
|
def decorator(func: Callable):
|
|
21
35
|
self._handlers[topic] = (func, data_type, group_id)
|
|
22
|
-
logger.info(
|
|
36
|
+
logger.info(
|
|
37
|
+
f"Registered Kafka input handler for topic '{topic}', data_type: {data_type}"
|
|
38
|
+
)
|
|
23
39
|
|
|
24
40
|
if self._running:
|
|
25
|
-
asyncio.create_task(
|
|
41
|
+
asyncio.create_task(
|
|
42
|
+
self._start_consumer(topic, func, data_type, group_id)
|
|
43
|
+
)
|
|
26
44
|
return func
|
|
45
|
+
|
|
27
46
|
return decorator
|
|
28
47
|
|
|
29
48
|
def set_bootstrap_servers(self, bootstrap_servers: str) -> None:
|
|
@@ -40,13 +59,17 @@ class KafkaApp:
|
|
|
40
59
|
async with self._lock:
|
|
41
60
|
for topic, (handler, data_type, group_id) in self._handlers.items():
|
|
42
61
|
if topic not in self._consumer_tasks:
|
|
43
|
-
self._consumer_tasks[topic] = asyncio.create_task(
|
|
62
|
+
self._consumer_tasks[topic] = asyncio.create_task(
|
|
63
|
+
self._start_consumer(topic, handler, data_type, group_id)
|
|
64
|
+
)
|
|
44
65
|
|
|
45
66
|
self._running = True
|
|
46
67
|
while self._running:
|
|
47
68
|
await asyncio.sleep(1)
|
|
48
69
|
|
|
49
|
-
async def _start_consumer(
|
|
70
|
+
async def _start_consumer(
|
|
71
|
+
self, topic: str, handler: Callable, data_type: type, group_id: str
|
|
72
|
+
):
|
|
50
73
|
consumer = AIOKafkaConsumer(
|
|
51
74
|
topic,
|
|
52
75
|
bootstrap_servers=self.bootstrap_servers,
|
|
@@ -65,16 +88,62 @@ class KafkaApp:
|
|
|
65
88
|
finally:
|
|
66
89
|
await consumer.stop()
|
|
67
90
|
|
|
68
|
-
async def _dispatch_message(
|
|
91
|
+
async def _dispatch_message(
|
|
92
|
+
self, handler: Callable, msg: ConsumerRecord, data_type: type
|
|
93
|
+
):
|
|
94
|
+
header_carrier: dict[str, str] = {}
|
|
95
|
+
for key, value in msg.headers or []:
|
|
96
|
+
try:
|
|
97
|
+
header_carrier[key] = (
|
|
98
|
+
value.decode("utf-8")
|
|
99
|
+
if isinstance(value, (bytes, bytearray))
|
|
100
|
+
else str(value)
|
|
101
|
+
)
|
|
102
|
+
except Exception:
|
|
103
|
+
header_carrier[key] = ""
|
|
104
|
+
|
|
69
105
|
try:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
106
|
+
parent_context = extract(header_carrier)
|
|
107
|
+
except Exception:
|
|
108
|
+
parent_context = otel_context_api.get_current()
|
|
109
|
+
if parent_context is None:
|
|
110
|
+
parent_context = otel_context_api.get_current()
|
|
111
|
+
|
|
112
|
+
span_name = f"Kafka consume {msg.topic}"
|
|
113
|
+
with self._tracer.start_as_current_span(
|
|
114
|
+
span_name,
|
|
115
|
+
context=parent_context,
|
|
116
|
+
kind=SpanKind.CONSUMER,
|
|
117
|
+
) as span:
|
|
118
|
+
span.set_attribute("messaging.system", "kafka")
|
|
119
|
+
span.set_attribute("messaging.destination", msg.topic)
|
|
120
|
+
span.set_attribute("messaging.kafka.partition", msg.partition)
|
|
121
|
+
span.set_attribute("messaging.kafka.offset", msg.offset)
|
|
122
|
+
|
|
123
|
+
execution_context = ExecutionContext(
|
|
124
|
+
trace_context=otel_context_api.get_current(),
|
|
125
|
+
span=span,
|
|
126
|
+
logger=logger.bind(topic=msg.topic),
|
|
127
|
+
metadata={
|
|
128
|
+
"entrypoint": "kafka",
|
|
129
|
+
"topic": msg.topic,
|
|
130
|
+
"partition": msg.partition,
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
token = set_current_context(execution_context)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
try:
|
|
137
|
+
data = data_type()
|
|
138
|
+
data.ParseFromString(msg.value)
|
|
139
|
+
except Exception as e:
|
|
140
|
+
print("Error:", e)
|
|
141
|
+
data = data_type(**json.loads(msg.value.decode("utf-8")))
|
|
142
|
+
result = handler(data)
|
|
143
|
+
if inspect.iscoroutine(result):
|
|
144
|
+
await result
|
|
145
|
+
finally:
|
|
146
|
+
reset_current_context(token)
|
|
78
147
|
|
|
79
148
|
async def _start_producer(self):
|
|
80
149
|
if self._producer is None:
|
|
@@ -94,14 +163,31 @@ class KafkaApp:
|
|
|
94
163
|
async def send_message(self, topic: str, data_type: type, data: Any) -> None:
|
|
95
164
|
if not self._running:
|
|
96
165
|
raise RuntimeError("KafkaApp is not running. Call start() first.")
|
|
97
|
-
|
|
166
|
+
|
|
98
167
|
if self._producer is None:
|
|
99
168
|
raise RuntimeError("Kafka producer is not initialized.")
|
|
100
|
-
|
|
169
|
+
|
|
170
|
+
headers_carrier: dict[str, str] = {}
|
|
171
|
+
execution_context = get_current_context()
|
|
172
|
+
try:
|
|
173
|
+
if execution_context and execution_context.trace_context is not None:
|
|
174
|
+
inject(headers_carrier, context=execution_context.trace_context)
|
|
175
|
+
else:
|
|
176
|
+
inject(headers_carrier)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.warning(f"inject trace context failed: {e}")
|
|
179
|
+
|
|
101
180
|
try:
|
|
102
|
-
await self._producer.send_and_wait(
|
|
181
|
+
await self._producer.send_and_wait(
|
|
182
|
+
topic,
|
|
183
|
+
data,
|
|
184
|
+
headers=[
|
|
185
|
+
(key, str(value).encode("utf-8"))
|
|
186
|
+
for key, value in headers_carrier.items()
|
|
187
|
+
],
|
|
188
|
+
)
|
|
103
189
|
logger.info(f"✅ 已发送消息到 topic '{topic}', type: {data_type}")
|
|
104
|
-
|
|
190
|
+
|
|
105
191
|
except Exception as e:
|
|
106
192
|
logger.error(f"❌ 发送消息到 topic '{topic}' 失败: {e}")
|
|
107
193
|
raise
|
|
@@ -109,18 +195,20 @@ class KafkaApp:
|
|
|
109
195
|
async def stop(self):
|
|
110
196
|
logger.info("Stopping KafkaApp ...")
|
|
111
197
|
self._running = False
|
|
112
|
-
|
|
198
|
+
|
|
113
199
|
for t in list(self._consumer_tasks.values()):
|
|
114
200
|
t.cancel()
|
|
115
201
|
await asyncio.sleep(0.1)
|
|
116
202
|
self._consumer_tasks.clear()
|
|
117
|
-
|
|
203
|
+
|
|
118
204
|
await self._stop_producer()
|
|
119
|
-
|
|
205
|
+
|
|
120
206
|
logger.info("✅ KafkaApp stopped")
|
|
121
207
|
|
|
208
|
+
|
|
122
209
|
kafka_app = KafkaApp()
|
|
123
210
|
|
|
211
|
+
|
|
124
212
|
async def start_kafka_server(bootstrap_servers: str):
|
|
125
213
|
kafka_app.set_bootstrap_servers(bootstrap_servers)
|
|
126
|
-
await kafka_app.start()
|
|
214
|
+
await kafka_app.start()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from typing import Type
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter
|
|
4
|
+
from omegaconf import OmegaConf
|
|
5
|
+
|
|
6
|
+
from service_forge.model.meta_api.schema import NodeTypeSchema, NodeInputPortSchema, NodeOutputPortSchema
|
|
7
|
+
from service_forge.sft.config.sf_metadata import load_metadata
|
|
8
|
+
from service_forge.workflow.node import Node, node_register
|
|
9
|
+
from service_forge.workflow.trigger import Trigger
|
|
10
|
+
|
|
11
|
+
def _node_class_to_schema(node_class: Type[Node]) -> NodeTypeSchema:
|
|
12
|
+
"""将Node子类转化为NodeTypeSchema"""
|
|
13
|
+
_input_ports = {}
|
|
14
|
+
for port in node_class.DEFAULT_INPUT_PORTS:
|
|
15
|
+
_input_ports[port.name] = NodeInputPortSchema(
|
|
16
|
+
name=port.name,
|
|
17
|
+
type=port.type.__name__,
|
|
18
|
+
default_value=port.default
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_output_ports = {}
|
|
22
|
+
for port in node_class.DEFAULT_OUTPUT_PORTS:
|
|
23
|
+
_output_ports[port.name] = NodeOutputPortSchema(
|
|
24
|
+
name=port.name,
|
|
25
|
+
type=port.type.__name__
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
return NodeTypeSchema(
|
|
29
|
+
name = node_class.__name__,
|
|
30
|
+
inputs=_input_ports,
|
|
31
|
+
outputs=_output_ports,
|
|
32
|
+
is_trigger=issubclass(node_class, Trigger)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def create_schema_router() -> APIRouter:
|
|
37
|
+
router = APIRouter(prefix='/schema')
|
|
38
|
+
|
|
39
|
+
@router.get('/nodes', response_model=list[NodeTypeSchema])
|
|
40
|
+
def get_node_schema():
|
|
41
|
+
schemas = []
|
|
42
|
+
for k, v in node_register.items.items():
|
|
43
|
+
if v is not Trigger:
|
|
44
|
+
schemas.append(_node_class_to_schema(v))
|
|
45
|
+
return schemas
|
|
46
|
+
|
|
47
|
+
return router
|
|
48
|
+
|
|
49
|
+
def create_meta_api_router() -> APIRouter:
|
|
50
|
+
router = APIRouter(tags=['meta-api'], prefix='/sdk/meta-api')
|
|
51
|
+
router.include_router(create_schema_router())
|
|
52
|
+
return router
|
|
53
|
+
|
|
54
|
+
metadata = load_metadata('sf-meta.yaml')
|
|
55
|
+
service_config = OmegaConf.load(metadata.service_config)
|
|
56
|
+
|
|
57
|
+
meta_api_router = create_meta_api_router()
|