service-forge 0.1.28__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 +127 -53
- 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 +56 -1
- service_forge/api/service_studio.py +9 -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 +32 -11
- service_forge/service_config.py +14 -0
- service_forge/sft/config/injector.py +32 -2
- service_forge/sft/config/injector_default_files.py +12 -0
- service_forge/sft/config/sf_metadata.py +5 -0
- service_forge/sft/config/sft_config.py +18 -0
- service_forge/telemetry.py +66 -0
- service_forge/workflow/node.py +266 -27
- service_forge/workflow/triggers/fast_api_trigger.py +61 -28
- service_forge/workflow/triggers/websocket_api_trigger.py +31 -10
- service_forge/workflow/workflow.py +87 -10
- service_forge/workflow/workflow_callback.py +24 -2
- service_forge/workflow/workflow_factory.py +13 -0
- {service_forge-0.1.28.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.28.dist-info/RECORD +0 -85
- {service_forge-0.1.28.dist-info → service_forge-0.1.39.dist-info}/WHEEL +0 -0
- {service_forge-0.1.28.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
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,9 +44,9 @@ 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
|
|
|
@@ -45,21 +59,21 @@ def validate_auth_from_headers(
|
|
|
45
59
|
) -> tuple[str | None, str | None]:
|
|
46
60
|
"""
|
|
47
61
|
Validate authentication from headers and return user_id and token.
|
|
48
|
-
|
|
62
|
+
|
|
49
63
|
Args:
|
|
50
64
|
headers: Dictionary of headers (can be from Request or WebSocket)
|
|
51
65
|
origin: Origin header value
|
|
52
66
|
scheme: URL scheme (http/https/ws/wss)
|
|
53
67
|
host: Host header value
|
|
54
68
|
trusted_domain: Trusted domain for origin validation
|
|
55
|
-
|
|
69
|
+
|
|
56
70
|
Returns:
|
|
57
71
|
tuple: (user_id, auth_token) - user_id can be None if not authenticated and not same origin
|
|
58
72
|
"""
|
|
59
73
|
is_same_origin = False
|
|
60
|
-
|
|
74
|
+
|
|
61
75
|
logger.debug(f"origin {origin}, host:{host}")
|
|
62
|
-
|
|
76
|
+
|
|
63
77
|
if origin and host:
|
|
64
78
|
try:
|
|
65
79
|
parsed_origin = urlparse(origin)
|
|
@@ -71,10 +85,10 @@ def validate_auth_from_headers(
|
|
|
71
85
|
)
|
|
72
86
|
except Exception:
|
|
73
87
|
pass
|
|
74
|
-
|
|
88
|
+
|
|
75
89
|
user_id = headers.get("X-User-ID")
|
|
76
90
|
token = headers.get("X-User-Token")
|
|
77
|
-
|
|
91
|
+
|
|
78
92
|
if not is_same_origin:
|
|
79
93
|
# For cross-origin requests, user_id is required
|
|
80
94
|
if not user_id:
|
|
@@ -91,18 +105,21 @@ async def authenticate_websocket(
|
|
|
91
105
|
) -> None:
|
|
92
106
|
"""
|
|
93
107
|
Authenticate WebSocket connection and set user_id and auth_token in websocket.state.
|
|
94
|
-
|
|
108
|
+
|
|
95
109
|
Args:
|
|
96
110
|
websocket: WebSocket instance
|
|
97
111
|
trusted_domain: Trusted domain for origin validation
|
|
98
|
-
|
|
112
|
+
|
|
99
113
|
Raises:
|
|
100
114
|
WebSocketException: If authentication fails
|
|
101
115
|
"""
|
|
116
|
+
if not websocket.url.path.startswith("/api"):
|
|
117
|
+
return
|
|
118
|
+
|
|
102
119
|
origin = websocket.headers.get("origin") or websocket.headers.get("referer")
|
|
103
120
|
scheme = websocket.url.scheme
|
|
104
121
|
host = websocket.headers.get("host", "")
|
|
105
|
-
|
|
122
|
+
|
|
106
123
|
user_id, token = validate_auth_from_headers(
|
|
107
124
|
websocket.headers,
|
|
108
125
|
origin,
|
|
@@ -110,10 +127,10 @@ async def authenticate_websocket(
|
|
|
110
127
|
host,
|
|
111
128
|
trusted_domain,
|
|
112
129
|
)
|
|
113
|
-
|
|
130
|
+
|
|
114
131
|
if user_id is None:
|
|
115
132
|
raise WebSocketException(code=1008, reason="Unauthorized")
|
|
116
|
-
|
|
133
|
+
|
|
117
134
|
websocket.state.user_id = user_id
|
|
118
135
|
websocket.state.auth_token = token
|
|
119
136
|
|
|
@@ -128,14 +145,14 @@ def create_app(
|
|
|
128
145
|
) -> FastAPI:
|
|
129
146
|
"""
|
|
130
147
|
Create or configure a FastAPI app with common middleware and configuration.
|
|
131
|
-
|
|
148
|
+
|
|
132
149
|
Args:
|
|
133
150
|
app: Optional existing FastAPI instance. If None, creates a new one.
|
|
134
151
|
routers: List of APIRouter instances to include
|
|
135
152
|
cors_origins: List of allowed CORS origins. Defaults to ["*"]
|
|
136
153
|
enable_auth_middleware: Whether to enable authentication middleware
|
|
137
154
|
trusted_domain: Trusted domain for origin validation
|
|
138
|
-
|
|
155
|
+
|
|
139
156
|
Returns:
|
|
140
157
|
FastAPI: Configured FastAPI application instance
|
|
141
158
|
"""
|
|
@@ -145,7 +162,7 @@ def create_app(
|
|
|
145
162
|
# Configure CORS middleware
|
|
146
163
|
if cors_origins is None:
|
|
147
164
|
cors_origins = ["*"]
|
|
148
|
-
|
|
165
|
+
|
|
149
166
|
app.add_middleware(
|
|
150
167
|
CORSMiddleware,
|
|
151
168
|
allow_origins=cors_origins,
|
|
@@ -154,6 +171,49 @@ def create_app(
|
|
|
154
171
|
allow_headers=["*"],
|
|
155
172
|
)
|
|
156
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
|
+
|
|
157
217
|
# Include routers if provided
|
|
158
218
|
if routers:
|
|
159
219
|
for router in routers:
|
|
@@ -169,42 +229,53 @@ def create_app(
|
|
|
169
229
|
# Include Feedback router
|
|
170
230
|
app.include_router(feedback_router)
|
|
171
231
|
|
|
172
|
-
# Always include Service router
|
|
232
|
+
# Always include Service router, Meta API Router, Trace Router, and Static Files
|
|
173
233
|
app.include_router(service_router)
|
|
174
|
-
|
|
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
|
+
|
|
175
245
|
# Add authentication middleware if enabled
|
|
176
|
-
if enable_auth_middleware:
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
+
|
|
206
276
|
return app
|
|
207
277
|
|
|
278
|
+
|
|
208
279
|
async def start_fastapi_server(host: str, port: int):
|
|
209
280
|
try:
|
|
210
281
|
config = uvicorn.Config(
|
|
@@ -222,7 +293,10 @@ async def start_fastapi_server(host: str, port: int):
|
|
|
222
293
|
|
|
223
294
|
try:
|
|
224
295
|
metadata = load_metadata("sf-meta.yaml")
|
|
225
|
-
|
|
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)}")
|
|
226
300
|
except Exception as e:
|
|
227
301
|
logger.warning(f"Failed to load metadata, using default configuration: {e}")
|
|
228
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()
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import os
|
|
2
3
|
import uuid
|
|
3
4
|
import tempfile
|
|
4
5
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
|
6
|
+
from fastapi.params import Body, Path
|
|
5
7
|
from fastapi.responses import JSONResponse
|
|
6
8
|
from loguru import logger
|
|
7
|
-
from typing import Optional, TYPE_CHECKING
|
|
9
|
+
from typing import Optional, TYPE_CHECKING, Dict, Any
|
|
8
10
|
from pydantic import BaseModel
|
|
9
11
|
from omegaconf import OmegaConf
|
|
10
12
|
from service_forge.current_service import get_service
|
|
@@ -18,18 +20,32 @@ class WorkflowStatusResponse(BaseModel):
|
|
|
18
20
|
workflows: list[dict]
|
|
19
21
|
|
|
20
22
|
class WorkflowActionResponse(BaseModel):
|
|
21
|
-
workflow_id: str
|
|
23
|
+
workflow_id: Optional[str] = None
|
|
22
24
|
success: bool
|
|
23
25
|
message: str
|
|
26
|
+
task_id: Optional[str] = None
|
|
24
27
|
|
|
25
28
|
@service_router.get("/status", response_model=WorkflowStatusResponse)
|
|
26
29
|
async def get_service_status():
|
|
27
30
|
service = get_service()
|
|
28
31
|
if service is None:
|
|
29
32
|
raise HTTPException(status_code=503, detail="Service not initialized")
|
|
30
|
-
|
|
33
|
+
# 排除调试版本
|
|
34
|
+
try:
|
|
35
|
+
status = service.get_service_status(exclude_debug=True)
|
|
36
|
+
return status
|
|
37
|
+
except Exception as e:
|
|
38
|
+
logger.error(f"Error getting service status: {e}")
|
|
39
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
40
|
+
|
|
41
|
+
@service_router.get("/workflow/{workflow_id}/status", response_model=dict)
|
|
42
|
+
def get_workflow_data(workflow_id: str):
|
|
43
|
+
service = get_service()
|
|
44
|
+
if service is None:
|
|
45
|
+
raise HTTPException(status_code=503, detail="Service not initialized")
|
|
46
|
+
|
|
31
47
|
try:
|
|
32
|
-
status = service.
|
|
48
|
+
status = service.get_workflow_status(workflow_id)
|
|
33
49
|
return status
|
|
34
50
|
except Exception as e:
|
|
35
51
|
logger.error(f"Error getting service status: {e}")
|
|
@@ -42,7 +58,7 @@ async def start_workflow(workflow_id: str):
|
|
|
42
58
|
raise HTTPException(status_code=503, detail="Service not initialized")
|
|
43
59
|
|
|
44
60
|
try:
|
|
45
|
-
success = service.start_workflow_by_id(
|
|
61
|
+
success = service.start_workflow_by_id(workflow_id)
|
|
46
62
|
if success:
|
|
47
63
|
return WorkflowActionResponse(success=True, message=f"Workflow {workflow_id} started successfully")
|
|
48
64
|
else:
|
|
@@ -58,7 +74,7 @@ async def stop_workflow(workflow_id: str):
|
|
|
58
74
|
raise HTTPException(status_code=503, detail="Service not initialized")
|
|
59
75
|
|
|
60
76
|
try:
|
|
61
|
-
success = await service.stop_workflow_by_id(
|
|
77
|
+
success = await service.stop_workflow_by_id(workflow_id)
|
|
62
78
|
if success:
|
|
63
79
|
return WorkflowActionResponse(success=True, message=f"Workflow {workflow_id} stopped successfully")
|
|
64
80
|
else:
|
|
@@ -67,6 +83,26 @@ async def stop_workflow(workflow_id: str):
|
|
|
67
83
|
logger.error(f"Error stopping workflow {workflow_id}: {e}")
|
|
68
84
|
raise HTTPException(status_code=500, detail=str(e))
|
|
69
85
|
|
|
86
|
+
class TriggerWorkflowRequest(BaseModel):
|
|
87
|
+
kwargs: Optional[Dict[str, Any]] = {}
|
|
88
|
+
|
|
89
|
+
@service_router.post("/workflow/{workflow_id}/trigger", response_model=WorkflowActionResponse)
|
|
90
|
+
async def trigger_workflow(workflow_id: str = Path(...), request_body: TriggerWorkflowRequest = Body(...)):
|
|
91
|
+
service = get_service()
|
|
92
|
+
if service is None:
|
|
93
|
+
raise HTTPException(status_code=503, detail="Service not initialized")
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
task_id = service.trigger_workflow_by_id(workflow_id, "", None, **request_body.kwargs) # Trigger Name实际上没有使用
|
|
97
|
+
if task_id is not None:
|
|
98
|
+
return WorkflowActionResponse(workflow_id=workflow_id, task_id=str(task_id), success=True, message=f"Workflow {workflow_id} triggered successfully with task_id {task_id}")
|
|
99
|
+
else:
|
|
100
|
+
return WorkflowActionResponse(workflow_id=workflow_id, success=False, message=f"Failed to trigger workflow {workflow_id}")
|
|
101
|
+
except Exception as e:
|
|
102
|
+
logger.error(f"Error triggering workflow {workflow_id}: {e}")
|
|
103
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
104
|
+
|
|
105
|
+
|
|
70
106
|
@service_router.post("/workflow/upload", response_model=WorkflowActionResponse)
|
|
71
107
|
async def upload_workflow_config(
|
|
72
108
|
file: Optional[UploadFile] = File(None),
|