diff --git a/services/data-ana/pyproject.toml b/services/data-ana/pyproject.toml index 632e8a8..1e95315 100644 --- a/services/data-ana/pyproject.toml +++ b/services/data-ana/pyproject.toml @@ -14,6 +14,8 @@ dependencies = [ "grpcio-health-checking>=1.66.0", # protobuf 运行时(buf generate 生成的 stub 依赖) "protobuf>=5.28.0", + # GraphQL Federation 2 子图(v2.1 M1,Apollo Router 组合) + "strawberry-graphql[asgi]>=0.257.0", ] [tool.uv.sources] diff --git a/services/data-ana/src/data_ana/config.py b/services/data-ana/src/data_ana/config.py index 15b3eae..06c4c0b 100644 --- a/services/data-ana/src/data_ana/config.py +++ b/services/data-ana/src/data_ana/config.py @@ -84,6 +84,12 @@ class Settings(BaseSettings): readyz_iam_grpc_timeout_s: float = 2.0 readyz_cdc_lag_threshold: int = 1000 # lag 超过此值判定 not_ready + # GraphQL Federation 2 子图(v2.1 M1,ADR-036) + # Apollo Router 请求子图时必须携带 Router-Authorization Header(共享密钥) + # 与 TS 服务使用相同的 secret(由 Secret 注入) + router_auth_secret: str = "" # 留空则 /graphql 返回 403(除非 dev_mode=True) + graphql_path: str = "/graphql" # GraphQL 端点路径 + # 向后兼容:旧代码引用 settings.port / settings.kafka_group_id @property def port(self) -> int: diff --git a/services/data-ana/src/data_ana/graphql/__init__.py b/services/data-ana/src/data_ana/graphql/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/data-ana/src/data_ana/graphql/router_auth.py b/services/data-ana/src/data_ana/graphql/router_auth.py new file mode 100644 index 0000000..f529b42 --- /dev/null +++ b/services/data-ana/src/data_ana/graphql/router_auth.py @@ -0,0 +1,102 @@ +"""GraphQL 端点 Router-Authorization 校验(v2.1 M1, ADR-036). + +对齐 TS 服务的 RouterAuthGuard(packages/shared-ts/src/federation/router-auth.guard.ts). + +强制约束: + - Apollo Router 请求子图时必须携带 ``Router-Authorization`` Header + - 拒绝任何非 Router 发起的 GraphQL 请求 + - 健康检查端点豁免 + - dev_mode=True 时跳过校验(仅限本地) + +部署模式: + - 生产:``DATA_ANA_ROUTER_AUTH_SECRET`` 环境变量配置共享密钥(与 TS 服务相同) + - 开发:``DATA_ANA_DEV_MODE=true`` 时跳过校验 +""" + +from typing import Any + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from ..config import settings + +ROUTER_AUTH_HEADER = "router-authorization" + +# 健康检查端点豁免前缀(这些路径不经过 GraphQL 校验) +_EXEMPT_PATH_PREFIXES: tuple[str, ...] = ( + "/health", + "/ready", + "/healthz", + "/readyz", + "/metrics", + "/docs", + "/openapi.json", + "/redoc", +) + + +def _clean_path(path: str) -> str: + """去除 query string 和 fragment.""" + return path.split("?", 1)[0].split("#", 1)[0] + + +def is_graphql_request(path: str) -> bool: + """判断是否为 GraphQL 端点请求.""" + clean = _clean_path(path) + graphql_path = settings.graphql_path + return clean == graphql_path or clean.startswith(graphql_path + "/") + + +def _auth_failure_response(status_code: int, detail: str) -> JSONResponse: + """构造认证失败响应(直接返回 JSONResponse,避免依赖异常传播).""" + return JSONResponse(status_code=status_code, content={"detail": detail}) + + +class RouterAuthMiddleware(BaseHTTPMiddleware): + """FastAPI 中间件:仅对 /graphql 端点校验 Router-Authorization Header. + + 对齐 NestJS 的 RouterAuthGuard(packages/shared-ts/src/federation/router-auth.guard.ts). + + 实现说明: + - 在 ``dispatch`` 内直接返回 JSONResponse 而非抛 HTTPException, + 避免 BaseHTTPMiddleware 异常传播到 ServerErrorMiddleware 时被吞为 500. + """ + + async def dispatch( + self, + request: Request, + call_next: Any, # noqa: ANN401 - Starlette 接口约定为 callable + ) -> Response: + # 1. dev_mode 跳过校验(仅限本地开发) + if settings.dev_mode: + return await call_next(request) + + path = request.url.path + + # 2. 非 GraphQL 路径放行(REST 路由已有 PermissionGuard) + if not is_graphql_request(path): + return await call_next(request) + + # 3. 健康检查端点豁免 + clean = _clean_path(path) + if any(clean == p or clean.startswith(p + "/") for p in _EXEMPT_PATH_PREFIXES): + return await call_next(request) + + # 4. secret 未配置 → 503(生产配置错误) + expected = settings.router_auth_secret + if not expected: + return _auth_failure_response( + status_code=503, + detail="Router authorization not configured on server", + ) + + # 5. Header 缺失或不匹配 → 403 + provided = request.headers.get(ROUTER_AUTH_HEADER, "") + if not provided or provided != expected: + return _auth_failure_response( + status_code=403, + detail="Direct GraphQL access denied; must go through Apollo Router", + ) + + return await call_next(request) diff --git a/services/data-ana/src/data_ana/graphql/schema.py b/services/data-ana/src/data_ana/graphql/schema.py new file mode 100644 index 0000000..5203b72 --- /dev/null +++ b/services/data-ana/src/data_ana/graphql/schema.py @@ -0,0 +1,614 @@ +"""GraphQL Federation 2 子图 schema(v2.1 M1). + +使用 strawberry-graphql 构建 Apollo Federation 2 子图,供 Apollo Router 组合. + +设计要点: + - 所有 Resolver 委托给 analytics_service / mastery_service / clickhouse_repository + - 不重新实现业务逻辑,仅做数据形状映射(dict → strawberry type) + - 异步 Resolver(async def),与现有服务一致 + - 降级响应:ClickHouse 不可达时 service 层已返回骨架数据(零值), + Resolver 直接转换即可,不抛异常 + - UserContext 从请求头提取(x-user-id / x-user-roles),与 REST 端点一致 + - Router-Authorization 校验由 router_auth.RouterAuthMiddleware 在 main.py 中注册 +""" + +from typing import Any + +import strawberry +import structlog +from starlette.requests import Request +from starlette.responses import Response +from strawberry.asgi import GraphQL +from strawberry.federation import Schema +from strawberry.types import Info + +from .. import analytics_service +from ..shared.permissions import UserContext + +logger = structlog.get_logger(__name__) + + +# ===== 辅助函数 ===== + + +def _get_user_context(info: Info) -> UserContext: + """从 GraphQL context 提取 UserContext. + + Apollo Router 从 JWT 提取用户信息后注入 x-user-id / x-user-roles 头, + 与 REST 端点使用相同的 header 约定. + """ + user = info.context.get("user") + if isinstance(user, UserContext): + return user + # 降级:无 user context(如未配置 Gateway 注入头) + return UserContext(user_id="", roles=[]) + + +def _to_float(value: Any, default: float = 0.0) -> float: + """安全转换为 float(None / 异常返回 default).""" + try: + return float(value) if value is not None else default + except (TypeError, ValueError): + return default + + +def _to_int(value: Any, default: int = 0) -> int: + """安全转换为 int(None / 异常返回 default).""" + try: + return int(value) if value is not None else default + except (TypeError, ValueError): + return default + + +def _first_str(*candidates: Any) -> str: + """从多个候选值中取第一个非空字符串.""" + for c in candidates: + if c: + return str(c) + return "" + + +# ===== 类型定义 ===== + + +@strawberry.type +class WeakPoint: + """学生薄弱知识点.""" + + knowledge_point_id: str + title: str + mastery: float + error_count: int + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "WeakPoint": + return cls( + knowledge_point_id=_first_str(d.get("knowledgePointId"), d.get("knowledge_point_id")), + title=_first_str(d.get("title"), d.get("knowledge_point_id")), + mastery=_to_float(d.get("mastery")), + error_count=_to_int(d.get("errorCount") or d.get("error_count")), + ) + + +@strawberry.type +class ClassPerformance: + """班级成绩分析.""" + + class_id: str + average_score: float + pass_rate: float + total_students: int + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "ClassPerformance": + return cls( + class_id=_first_str(d.get("classId"), d.get("class_id")), + average_score=_to_float(d.get("averageScore")), + pass_rate=_to_float(d.get("passRate")), + total_students=_to_int(d.get("totalStudents")), + ) + + +@strawberry.type +class StudentWeakness: + """学生薄弱知识点列表.""" + + student_id: str + weak_points: list[WeakPoint] + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "StudentWeakness": + raw_points = d.get("weakPoints") or d.get("weak_points") or [] + return cls( + student_id=_first_str(d.get("studentId"), d.get("student_id")), + weak_points=[WeakPoint.from_dict(p) for p in raw_points], + ) + + +@strawberry.type +class TrendPoint: + """学习趋势数据点.""" + + date: int + score: float + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "TrendPoint": + return cls( + date=_to_int(d.get("date")), + score=_to_float(d.get("score")), + ) + + +@strawberry.type +class LearningTrend: + """学习趋势.""" + + student_id: str + points: list[TrendPoint] + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "LearningTrend": + raw_points = d.get("points") or [] + return cls( + student_id=_first_str(d.get("studentId"), d.get("student_id")), + points=[TrendPoint.from_dict(p) for p in raw_points], + ) + + +@strawberry.type +class TeacherDashboard: + """教师仪表盘.""" + + user_id: str + total_classes: int + total_students: int + class_avg_score: float + pending_homework_count: int + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "TeacherDashboard": + return cls( + user_id=_first_str(d.get("userId"), d.get("user_id")), + total_classes=_to_int(d.get("totalClasses")), + total_students=_to_int(d.get("totalStudents")), + class_avg_score=_to_float(d.get("classAvgScore") or d.get("averageScore")), + pending_homework_count=_to_int(d.get("pendingHomeworkCount")), + ) + + +@strawberry.type +class StudentDashboard: + """学生仪表盘.""" + + user_id: str + avg_score: float + class_rank: int + total_students: int + pending_homework: int + + @classmethod + def from_dict(cls, d: dict[str, Any], user_id: str = "") -> "StudentDashboard": + """从 student_dashboard service 结果构造. + + service 返回的 records 含 score / rank_in_class 等字段, + avg_score / class_rank 从 records 聚合. + total_students / pending_homework 需额外聚合(当前返回 0,P6 演进). + """ + records = d.get("records") or [] + scores = [r.get("score") for r in records if r.get("score") is not None] + avg_score = sum(scores) / len(scores) if scores else 0.0 + class_rank = _to_int(records[0].get("rank_in_class")) if records else 0 + return cls( + user_id=user_id or _first_str(d.get("studentId"), d.get("userId")), + avg_score=_to_float(avg_score), + class_rank=class_rank, + total_students=0, # service 未返回,需班级聚合查询(P6 演进) + pending_homework=0, # service 未返回,需 homework_submissions 聚合(P6 演进) + ) + + +@strawberry.type +class ParentDashboard: + """家长仪表盘.""" + + user_id: str + student_id: str + child_avg_score: float + child_class_rank: int + + @classmethod + def from_dict(cls, d: dict[str, Any], user_id: str = "") -> "ParentDashboard": + """从 parent_dashboard service 结果构造(复用 student_dashboard 查询).""" + records = d.get("records") or [] + scores = [r.get("score") for r in records if r.get("score") is not None] + avg_score = sum(scores) / len(scores) if scores else 0.0 + class_rank = _to_int(records[0].get("rank_in_class")) if records else 0 + return cls( + user_id=user_id, + student_id=_first_str(d.get("childId"), d.get("studentId")), + child_avg_score=_to_float(avg_score), + child_class_rank=class_rank, + ) + + +@strawberry.type +class AdminDashboard: + """管理员仪表盘.""" + + user_id: str + total_teachers: int + total_students: int + total_classes: int + school_avg_score: float + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "AdminDashboard": + """从 admin_dashboard service 结果构造. + + service 返回 teacher_dashboard 聚合数据 + AI 用量统计, + total_teachers 需 iam 聚合(当前返回 0,P6 演进). + """ + return cls( + user_id=_first_str(d.get("userId"), d.get("user_id")), + total_teachers=0, # service 未返回,需 iam 聚合(P6 演进) + total_students=_to_int(d.get("totalStudents")), + total_classes=_to_int(d.get("totalClasses")), + school_avg_score=_to_float(d.get("classAvgScore") or d.get("averageScore")), + ) + + +@strawberry.type +class StudentMastery: + """学生知识点掌握度明细.""" + + student_id: str + overall_mastery: float + mastered_count: int + progressing_count: int + weak_count: int + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "StudentMastery": + """从 mastery_snapshot service 结果构造,三档分布从 knowledgePoints 聚合.""" + kps = d.get("knowledgePoints") or [] + mastered = sum(1 for kp in kps if kp.get("mastery_label") == "mastered") + progressing = sum(1 for kp in kps if kp.get("mastery_label") == "progressing") + weak = sum(1 for kp in kps if kp.get("mastery_label") == "weak") + return cls( + student_id=_first_str(d.get("studentId"), d.get("student_id")), + overall_mastery=_to_float(d.get("overallMastery")), + mastered_count=mastered, + progressing_count=progressing, + weak_count=weak, + ) + + +@strawberry.type +class MasterySummary: + """学生掌握度汇总(轻量级).""" + + student_id: str + overall_mastery: float + mastered_count: int + progressing_count: int + weak_count: int + total_knowledge_points: int + mastery_level: str + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "MasterySummary": + return cls( + student_id=_first_str(d.get("studentId"), d.get("student_id")), + overall_mastery=_to_float(d.get("overallMastery")), + mastered_count=_to_int(d.get("masteredCount")), + progressing_count=_to_int(d.get("progressingCount")), + weak_count=_to_int(d.get("weakCount")), + total_knowledge_points=_to_int(d.get("totalKnowledgePoints")), + mastery_level=_first_str(d.get("masteryLevel")) or "weak", + ) + + +@strawberry.type +class ErrorBookItem: + """错题本条目.""" + + question_id: str + knowledge_point_id: str + knowledge_point_title: str + error_count: int + last_error_time: int + content: str + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "ErrorBookItem": + return cls( + question_id=_first_str(d.get("question_id"), d.get("questionId")), + knowledge_point_id=_first_str(d.get("knowledge_point_id"), d.get("knowledgePointId")), + knowledge_point_title=_first_str( + d.get("knowledge_point_title"), d.get("knowledgePointTitle") + ), + error_count=_to_int(d.get("error_count") or d.get("errorCount")), + last_error_time=_to_int(d.get("last_error_time") or d.get("lastErrorTime")), + content=d.get("content") or "", + ) + + +@strawberry.type +class ErrorBookStats: + """错题本统计.""" + + student_id: str + total_error_questions: int + total_error_count: int + recent_7d_errors: int + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "ErrorBookStats": + return cls( + student_id=_first_str(d.get("studentId"), d.get("student_id")), + total_error_questions=_to_int(d.get("totalErrorQuestions")), + total_error_count=_to_int(d.get("totalErrorCount")), + recent_7d_errors=_to_int(d.get("recent7dErrors") or d.get("recent_7d_errors")), + ) + + +# ===== Query 根类型(Resolver 委托给现有 service) ===== + + +@strawberry.type +class Query: + """GraphQL Query 根类型. + + 所有 Resolver 委托给 analytics_service / mastery_service / clickhouse_repository, + 不重新实现业务逻辑,仅做 dict → strawberry type 的形状映射. + """ + + @strawberry.field + async def class_performance( + self, + info: Info, + class_id: str, + subject_id: str = "", + start_date: int = 0, + end_date: int = 0, + ) -> ClassPerformance: + """班级成绩分析.""" + user = _get_user_context(info) + result = await analytics_service.get_class_performance( + user=user, + class_id=class_id, + subject_id=subject_id, + start_date=start_date, + end_date=end_date, + ) + return ClassPerformance.from_dict(result) + + @strawberry.field + async def student_weakness( + self, + info: Info, + student_id: str, + subject_id: str = "", + ) -> StudentWeakness: + """学生薄弱知识点.""" + user = _get_user_context(info) + result = await analytics_service.get_student_weakness( + user=user, + student_id=student_id, + subject_id=subject_id, + ) + return StudentWeakness.from_dict(result) + + @strawberry.field + async def learning_trend( + self, + info: Info, + student_id: str, + subject_id: str = "", + start_date: int = 0, + end_date: int = 0, + ) -> LearningTrend: + """学习趋势.""" + user = _get_user_context(info) + result = await analytics_service.get_learning_trend( + user=user, + student_id=student_id, + subject_id=subject_id, + start_date=start_date, + end_date=end_date, + ) + return LearningTrend.from_dict(result) + + @strawberry.field + async def teacher_dashboard( + self, + info: Info, + user_id: str, + class_id: str = "", + ) -> TeacherDashboard: + """教师仪表盘.""" + user = _get_user_context(info) + result = await analytics_service.get_teacher_dashboard( + user=user, + class_id=class_id, + ) + return TeacherDashboard.from_dict(result) + + @strawberry.field + async def student_dashboard( + self, + info: Info, + user_id: str, + ) -> StudentDashboard: + """学生仪表盘.""" + user = _get_user_context(info) + result = await analytics_service.get_student_dashboard( + user=user, + student_id=user_id, + ) + return StudentDashboard.from_dict(result, user_id=user_id) + + @strawberry.field + async def parent_dashboard( + self, + info: Info, + user_id: str, + student_id: str, + ) -> ParentDashboard: + """家长仪表盘.""" + user = _get_user_context(info) + result = await analytics_service.get_parent_dashboard( + user=user, + child_id=student_id, + ) + return ParentDashboard.from_dict(result, user_id=user_id) + + @strawberry.field + async def admin_dashboard( + self, + info: Info, + user_id: str, + scope: str = "", + scope_id: str = "", + ) -> AdminDashboard: + """管理员仪表盘.""" + user = _get_user_context(info) + result = await analytics_service.get_admin_dashboard( + user=user, + school_id=scope_id, + ) + return AdminDashboard.from_dict(result) + + @strawberry.field + async def student_mastery( + self, + info: Info, + student_id: str, + subject_id: str = "", + ) -> StudentMastery: + """学生知识点掌握度明细.""" + user = _get_user_context(info) + result = await analytics_service.get_student_mastery( + user=user, + student_id=student_id, + subject_id=subject_id, + ) + return StudentMastery.from_dict(result) + + @strawberry.field + async def mastery_summary( + self, + info: Info, + student_id: str, + subject_id: str = "", + ) -> MasterySummary: + """学生掌握度汇总.""" + user = _get_user_context(info) + result = await analytics_service.get_mastery_summary( + user=user, + student_id=student_id, + subject_id=subject_id, + ) + return MasterySummary.from_dict(result) + + @strawberry.field + async def error_book_items( + self, + info: Info, + student_id: str, + subject_id: str = "", + limit: int = 100, + offset: int = 0, + ) -> list[ErrorBookItem]: + """错题本列表.""" + user = _get_user_context(info) + result = await analytics_service.list_error_book_items( + user=user, + student_id=student_id, + subject_id=subject_id, + limit=limit, + offset=offset, + ) + items = result.get("items") or [] + return [ErrorBookItem.from_dict(item) for item in items] + + @strawberry.field + async def error_book_stats( + self, + info: Info, + student_id: str, + subject_id: str = "", + ) -> ErrorBookStats: + """错题本统计.""" + user = _get_user_context(info) + result = await analytics_service.get_error_book_stats( + user=user, + student_id=student_id, + subject_id=subject_id, + ) + return ErrorBookStats.from_dict(result) + + +# ===== Federation 2 Schema ===== + + +schema = Schema( + query=Query, + # 显式声明所有类型(确保 Federation 2 子图完整暴露) + types=[ + ClassPerformance, + StudentWeakness, + WeakPoint, + LearningTrend, + TrendPoint, + TeacherDashboard, + StudentDashboard, + ParentDashboard, + AdminDashboard, + StudentMastery, + MasterySummary, + ErrorBookItem, + ErrorBookStats, + ], +) + + +# ===== ASGI app + context getter ===== + + +async def _build_context(request: Request) -> dict[str, Any]: + """从请求头提取 UserContext(与 REST 端点使用相同的 header 约定). + + Apollo Router 从 JWT 提取用户信息后注入 x-user-id / x-user-roles 头. + """ + user_id = request.headers.get("x-user-id", "") + user_roles_header = request.headers.get("x-user-roles", "") + roles = ( + [r.strip() for r in user_roles_header.split(",") if r.strip()] if user_roles_header else [] + ) + return { + "request": request, + "user": UserContext(user_id=user_id, roles=roles), + } + + +class _DataAnaGraphQLApp(GraphQL): + """data-ana GraphQL ASGI handler. + + 重写 get_context 以注入 UserContext(strawberry 0.257+ 不再支持 + context_getter 构造参数,需通过子类重写). + """ + + async def get_context( + self, + request: Request, + response: Response, # noqa: ARG002 - strawberry 调用约定 + ) -> dict[str, Any]: + return await _build_context(request) + + +# GraphQL ASGI app(在 main.py 中挂载到 /graphql) +# 生产环境通过 Router-Authorization Header 校验(RouterAuthMiddleware 拦截) +# 开发环境通过 dev_mode 控制中间件放行(schema 本身不输出堆栈,避免泄露) +graphql_app: GraphQL = _DataAnaGraphQLApp(schema) diff --git a/services/data-ana/src/data_ana/main.py b/services/data-ana/src/data_ana/main.py index 4a499c6..6dfcd9f 100644 --- a/services/data-ana/src/data_ana/main.py +++ b/services/data-ana/src/data_ana/main.py @@ -1,6 +1,6 @@ """数据分析服务入口(FastAPI HTTP :3006). -端点清单(3 基础 + 11 业务 = 14 个): +端点清单(3 基础 + 11 业务 + 1 GraphQL = 15 个): 基础: GET / 根信息 GET /healthz 活性检查(liveness) @@ -20,12 +20,18 @@ GET /analytics/class/{class_id}/mastery-distribution 班级掌握度分布 GET /analytics/student/{student_id}/mastery 学生掌握度明细 + GraphQL(v2.1 M1,Apollo Federation 2 子图): + POST /graphql GraphQL 端点(Router-Authorization 校验) + - Resolver 委托给 analytics_service / mastery_service + - 由 Apollo Router 组合(不直接对外暴露,dev_mode 除外) + 设计要点: - 所有业务端点返回 ActionState[T](coord-cross-review §5.3 P0 整改) - 降级标记在顶层 details.degraded(不放 error.details) - /readyz 检查 4 依赖:clickhouse / cdc_consumer / redis / iam_grpc - gRPC server :50055 在 lifespan 启动 - CDC 消费者在 lifespan 启动 + - /graphql 由 strawberry-graphql 提供,RouterAuthMiddleware 拦截 """ from collections.abc import AsyncGenerator @@ -44,6 +50,8 @@ from prometheus_client import make_asgi_app from . import analytics_service, cdc_consumer, grpc_server, warning_service from .config import settings +from .graphql.router_auth import RouterAuthMiddleware +from .graphql.schema import graphql_app from .repository import ( clickhouse_repository, iam_client, @@ -181,6 +189,13 @@ app = FastAPI( FastAPIInstrumentor.instrument_app(app) app.mount("/metrics", make_asgi_app()) +# GraphQL Federation 2 子图(v2.1 M1) +# RouterAuthMiddleware 仅对 /graphql 端点校验 Router-Authorization Header, +# REST 路由放行(已有 PermissionGuard / DataScope 校验) +app.add_middleware(RouterAuthMiddleware) +# 挂载 strawberry ASGI handler 到 /graphql(Apollo Router 访问入口) +app.mount(settings.graphql_path, graphql_app) + router = APIRouter() @@ -200,6 +215,7 @@ async def root() -> dict[str, Any]: "healthz": "/healthz", "readyz": "/readyz", "business": "/analytics/*", + "graphql": settings.graphql_path, }, } diff --git a/uv.lock b/uv.lock index 571440f..afec057 100644 --- a/uv.lock +++ b/uv.lock @@ -27,6 +27,7 @@ dependencies = [ { name = "protobuf" }, { name = "pyyaml" }, { name = "redis" }, + { name = "strawberry-graphql", extra = ["asgi"] }, { name = "tenacity" }, ] @@ -54,6 +55,7 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0.2" }, { name = "redis", specifier = ">=5.1.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7.0" }, + { name = "strawberry-graphql", extras = ["asgi"], specifier = ">=0.257.0" }, { name = "tenacity", specifier = ">=9.0.0" }, ] provides-extras = ["dev"] @@ -349,6 +351,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, ] +[[package]] +name = "cross-web" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/a0/bdb8370215987cc5bde497cb8b976b17ab14841566ecef2aab6ae3e6c7b0/cross_web-0.7.0.tar.gz", hash = "sha256:15fbc8b9a824a055db8127fd6e43e0773074f620fdecb6b2b587d3d0a2bdd459", size = 332407, upload-time = "2026-05-19T14:18:48.849Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/4a/78b52ec2edcbd9b123638f4e0421fd8699ebe653ebf63ac5f82abd2665bc/cross_web-0.7.0-py3-none-any.whl", hash = "sha256:ddea9be3c68b48eaf16561847a5831a559786949c544b3701432e00a4e8d19d9", size = 25207, upload-time = "2026-05-19T14:18:47.614Z" }, +] + [[package]] name = "data-ana-service" version = "1.0.0" @@ -359,6 +373,7 @@ dependencies = [ { name = "edu-shared-py" }, { name = "grpcio-health-checking" }, { name = "protobuf" }, + { name = "strawberry-graphql", extra = ["asgi"] }, ] [package.metadata] @@ -368,6 +383,7 @@ requires-dist = [ { name = "edu-shared-py", editable = "packages/shared-py" }, { name = "grpcio-health-checking", specifier = ">=1.66.0" }, { name = "protobuf", specifier = ">=5.28.0" }, + { name = "strawberry-graphql", extras = ["asgi"], specifier = ">=0.257.0" }, ] [[package]] @@ -437,6 +453,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, ] +[[package]] +name = "graphql-core" +version = "3.2.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/90/f2aff026ab4aebd80eb71905106a0885f4cfde85dcf965543f45bed0d9ee/graphql_core-3.2.11.tar.gz", hash = "sha256:e7e156d10beb127cab5c89ff0da71416fc73d27c484a4757d3b2d35633774802", size = 528407, upload-time = "2026-06-05T13:45:22.915Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl", hash = "sha256:0b3e35ff41e9adba53021ab0cef475eb18f57c7f53f0f2ca55567fbf3c537ea0", size = 214879, upload-time = "2026-06-05T13:45:21.245Z" }, +] + [[package]] name = "grpcio" version = "1.82.1" @@ -1121,6 +1146,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -1130,6 +1167,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1234,6 +1280,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "starlette" version = "1.3.1" @@ -1247,6 +1302,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] +[[package]] +name = "strawberry-graphql" +version = "0.321.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cross-web" }, + { name = "graphql-core" }, + { name = "packaging" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/b0/e2e13fadbf291fd7ee1c89a7f3d6872424ad299018a3adfd6ac8459ba21c/strawberry_graphql-0.321.0.tar.gz", hash = "sha256:54e916c83f21219bbe3067b40ca4d10d550a487ea6690336e3be9e5536409214", size = 233479, upload-time = "2026-07-13T20:56:02.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/b7/9d4d280ccb675fb4273a9e7f1d67b67fbd4ce7baaa15dba646bc20759915/strawberry_graphql-0.321.0-py3-none-any.whl", hash = "sha256:bd85d8a3eda351cc4a952d3d6216096b112c94ee9149e18c4cda754bb967c0d9", size = 336994, upload-time = "2026-07-13T20:56:01.075Z" }, +] + +[package.optional-dependencies] +asgi = [ + { name = "python-multipart" }, + { name = "starlette" }, +] + [[package]] name = "structlog" version = "26.1.0"