diff --git a/services/api-server/src/api_server/html5_project_controller.py b/services/api-server/src/api_server/html5_project_controller.py new file mode 100644 index 0000000..bc9e17d --- /dev/null +++ b/services/api-server/src/api_server/html5_project_controller.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Callable, Iterable +from typing import Any + +from fastapi import HTTPException + +from api_server.html5_authoring import render_html5_authoring_changes +from api_server.html5_editor import ( + render_html5_source, + render_html5_status, + render_html5_symbol_detail, + render_html5_symbols, +) +from api_server.html5_forms import form_value +from api_server.html5_inspector import render_html5_flowchart, render_html5_project_report, render_html5_review +from api_server.html5_projects import render_html5_index, render_html5_project_rows +from api_server.html5_sse import html5_sse_comment, html5_sse_if_changed +from sir import SirSnapshot + + +def html5_index_page(projects: Iterable[object]) -> str: + return render_html5_index(projects) + + +async def html5_create_project_rows( + *, + form: dict[str, list[str]], + create_project: Callable[[object], Any], + create_request: Callable[..., object], + project_summaries: Callable[[], Iterable[object]], +) -> str: + project_id = form_value(form, "project_id") + if not project_id: + raise HTTPException(status_code=400, detail="project_id is required.") + await create_project(create_request(project_id=project_id, name=form_value(form, "name"))) + return render_html5_project_rows(project_summaries()) + + +async def html5_delete_project_rows( + *, + project_id: str, + form: dict[str, list[str]], + delete_project: Callable[[str, object], Any], + delete_request: Callable[..., object], + project_summaries: Callable[[], Iterable[object]], +) -> str: + await delete_project(project_id, delete_request(confirmation=form_value(form, "confirmation") or "")) + return render_html5_project_rows(project_summaries()) + + +async def html5_project_event_stream( + *, + project_id: str, + project_snapshot: Callable[[str], SirSnapshot], + project_report: Callable[[str], Any], + review: Callable[[str], Any], + flowchart: Callable[..., Any], + authoring_changes: Callable[[str], Iterable[object]], + once: bool = False, +) -> AsyncIterator[str]: + last_fragments: dict[str, str] = {} + while True: + yield html5_sse_comment(f"project {project_id} heartbeat") + try: + snapshot = project_snapshot(project_id) + status = render_html5_status(project_id, snapshot) + report = await project_report(project_id) + findings = await review(project_id) + graph = await flowchart(project_id, focus=None, depth=1, limit=80) + except HTTPException as error: + status = f'project: {project_id}error: {error.detail}' + report = None + findings = None + graph = None + for event_text in html5_sse_if_changed(last_fragments, "status", status): + yield event_text + for event_text in html5_sse_if_changed( + last_fragments, + "authoring-changes", + render_html5_authoring_changes(project_id, authoring_changes(project_id)), + ): + yield event_text + if report is not None: + for event_text in html5_sse_if_changed(last_fragments, "project-report", render_html5_project_report(project_id, report)): + yield event_text + if findings is not None: + for event_text in html5_sse_if_changed(last_fragments, "project-review", render_html5_review(project_id, findings)): + yield event_text + if graph is not None: + for event_text in html5_sse_if_changed(last_fragments, "project-flowchart", render_html5_flowchart(project_id, graph)): + yield event_text + if once: + break + await asyncio.sleep(5) + + +def html5_project_symbols(*, snapshot: SirSnapshot, q: str, project_id: str) -> str: + return render_html5_symbols(snapshot, q, project_id) + + +def html5_project_symbol_detail(*, project_id: str, references: object) -> str: + return render_html5_symbol_detail(project_id, references) + + +def html5_project_source_by_path(*, snapshot: SirSnapshot, path: str) -> str: + node = next( + ( + item + for item in snapshot.nodes + if item.source_ref is not None and item.source_ref.source_path == path + ), + None, + ) + if node is None: + raise HTTPException(status_code=404, detail=f"Source not found: {path}") + return render_html5_source(node) + + +def html5_project_source_by_lineage(*, node: object | None, lineage_id: str) -> str: + if node is None: + raise HTTPException(status_code=404, detail=f"Lineage not found: {lineage_id}") + return render_html5_source(node) + + +def html5_project_report_fragment(*, project_id: str, report: object) -> str: + return render_html5_project_report(project_id, report) + + +def html5_project_review_fragment(*, project_id: str, findings: object) -> str: + return render_html5_review(project_id, findings) + + +def html5_project_flowchart_fragment(*, project_id: str, flowchart: object, focus: str | None, depth: int) -> str: + return render_html5_flowchart(project_id, flowchart, focus=focus, depth=depth) diff --git a/services/api-server/src/api_server/main.py b/services/api-server/src/api_server/main.py index df04413..8bba4fc 100644 --- a/services/api-server/src/api_server/main.py +++ b/services/api-server/src/api_server/main.py @@ -37,7 +37,6 @@ from neo4j import AsyncGraphDatabase from pydantic import BaseModel, Field from api_server.html5_forms import ( - form_value as _form_value, html5_form_data as _html5_form_data, ) from api_server.html5_editor_controller import ( @@ -45,22 +44,24 @@ from api_server.html5_editor_controller import ( html5_form_editor_page as _html5_form_editor_page, html5_object_context_fragment as _html5_object_context_fragment, ) -from api_server.html5_projects import render_html5_index, render_html5_project_rows +from api_server.html5_project_controller import ( + html5_create_project_rows as _html5_create_project_rows, + html5_delete_project_rows as _html5_delete_project_rows, + html5_index_page as _html5_index_page, + html5_project_event_stream as _html5_project_event_stream, + html5_project_flowchart_fragment as _html5_project_flowchart_fragment, + html5_project_report_fragment as _html5_project_report_fragment, + html5_project_review_fragment as _html5_project_review_fragment, + html5_project_source_by_lineage as _html5_project_source_by_lineage, + html5_project_source_by_path as _html5_project_source_by_path, + html5_project_symbol_detail as _html5_project_symbol_detail, + html5_project_symbols as _html5_project_symbols, +) from api_server.html5_responses import ( Html5StaticFiles, html5_response as _html5_response, html5_sse_response as _html5_sse_response, ) -from api_server.html5_sse import ( - html5_sse_comment as _html5_sse_comment, - html5_sse_if_changed as _html5_sse_if_changed, -) -from api_server.html5_inspector import ( - render_html5_flowchart, - render_html5_project_report, - render_html5_review, -) -from api_server.html5_authoring import render_html5_authoring_changes from api_server.html5_authoring_controller import ( html5_authoring_apply_change_set as _html5_authoring_apply_change_set, html5_authoring_apply_metadata_object as _html5_authoring_apply_metadata_object, @@ -71,12 +72,6 @@ from api_server.html5_authoring_controller import ( html5_authoring_metadata_object_preview as _html5_authoring_metadata_object_preview, html5_authoring_semantic_diff_preview as _html5_authoring_semantic_diff_preview, ) -from api_server.html5_editor import ( - render_html5_source, - render_html5_status, - render_html5_symbol_detail, - render_html5_symbols, -) from api_server.html5_operations import ( latest_html5_import_job, ) @@ -1643,25 +1638,34 @@ async def api_health() -> dict[str, str]: @app.get("/html5") async def html5_index() -> Response: - return _html5_response(render_html5_index(_project_summaries())) + return _html5_response(_html5_index_page(_project_summaries())) @app.post("/html5/projects") async def html5_create_project(request: Request) -> Response: form = await _html5_form_data(request) - project_id = _form_value(form, "project_id") - if not project_id: - raise HTTPException(status_code=400, detail="project_id is required.") - await create_project(ProjectCreateRequest(project_id=project_id, name=_form_value(form, "name"))) - return _html5_response(render_html5_project_rows(_project_summaries())) + return _html5_response( + await _html5_create_project_rows( + form=form, + create_project=create_project, + create_request=ProjectCreateRequest, + project_summaries=_project_summaries, + ) + ) @app.post("/html5/projects/{project_id}/delete") async def html5_delete_project(project_id: str, request: Request) -> Response: form = await _html5_form_data(request) - confirmation = _form_value(form, "confirmation") or "" - await delete_project(project_id, ProjectDeleteRequest(confirmation=confirmation)) - return _html5_response(render_html5_project_rows(_project_summaries())) + return _html5_response( + await _html5_delete_project_rows( + project_id=project_id, + form=form, + delete_project=delete_project, + delete_request=ProjectDeleteRequest, + project_summaries=_project_summaries, + ) + ) @app.get("/html5/operations") @@ -1751,92 +1755,54 @@ async def html5_project_form_editor(project_id: str, form: str | None = None) -> @app.get("/html5/projects/{project_id}/events") async def html5_project_events(project_id: str, once: bool = False) -> StreamingResponse: - async def stream_status(): - last_fragments: dict[str, str] = {} - while True: - yield _html5_sse_comment(f"project {project_id} heartbeat") - try: - snapshot = _project_snapshot_or_404(project_id) - fragment = render_html5_status(project_id, snapshot) - report = await project_report(project_id) - findings = await get_review(project_id) - flowchart = await project_flowchart(project_id, focus=None, depth=1, limit=80) - except HTTPException as error: - fragment = f'project: {project_id}error: {error.detail}' - report = None - findings = None - flowchart = None - for event_text in _html5_sse_if_changed(last_fragments, "status", fragment): - yield event_text - for event_text in _html5_sse_if_changed( - last_fragments, - "authoring-changes", - render_html5_authoring_changes(project_id, _authoring_change_summaries(project_id)), - ): - yield event_text - if report is not None: - for event_text in _html5_sse_if_changed(last_fragments, "project-report", render_html5_project_report(project_id, report)): - yield event_text - if findings is not None: - for event_text in _html5_sse_if_changed(last_fragments, "project-review", render_html5_review(project_id, findings)): - yield event_text - if flowchart is not None: - for event_text in _html5_sse_if_changed(last_fragments, "project-flowchart", render_html5_flowchart(project_id, flowchart)): - yield event_text - if once: - break - await asyncio.sleep(5) - - return _html5_sse_response(stream_status()) + return _html5_sse_response( + _html5_project_event_stream( + project_id=project_id, + project_snapshot=_project_snapshot_or_404, + project_report=project_report, + review=get_review, + flowchart=project_flowchart, + authoring_changes=_authoring_change_summaries, + once=once, + ) + ) @app.get("/html5/projects/{project_id}/symbols") async def html5_project_symbols(project_id: str, q: str = "") -> Response: snapshot = _project_snapshot_or_404(project_id) - return _html5_response(render_html5_symbols(snapshot, q, project_id)) + return _html5_response(_html5_project_symbols(snapshot=snapshot, q=q, project_id=project_id)) @app.get("/html5/projects/{project_id}/symbols/{lineage_id}/detail") async def html5_project_symbol_detail(project_id: str, lineage_id: str) -> Response: references = await project_symbol_references(project_id, lineage_id, direction="both") - return _html5_response(render_html5_symbol_detail(project_id, references)) + return _html5_response(_html5_project_symbol_detail(project_id=project_id, references=references)) @app.get("/html5/projects/{project_id}/source/by-path") async def html5_project_source_by_path(project_id: str, path: str) -> Response: snapshot = _project_snapshot_or_404(project_id) - node = next( - ( - item - for item in snapshot.nodes - if item.source_ref is not None and item.source_ref.source_path == path - ), - None, - ) - if node is None: - raise HTTPException(status_code=404, detail=f"Source not found: {path}") - return _html5_response(render_html5_source(node)) + return _html5_response(_html5_project_source_by_path(snapshot=snapshot, path=path)) @app.get("/html5/projects/{project_id}/source/{lineage_id}") async def html5_project_source(project_id: str, lineage_id: str) -> Response: snapshot = _project_snapshot_or_404(project_id) node = _find_snapshot_node(snapshot, lineage_id) - if node is None: - raise HTTPException(status_code=404, detail=f"Lineage not found: {lineage_id}") - return _html5_response(render_html5_source(node)) + return _html5_response(_html5_project_source_by_lineage(node=node, lineage_id=lineage_id)) @app.get("/html5/projects/{project_id}/report") async def html5_project_report(project_id: str) -> Response: report = await project_report(project_id) - return _html5_response(render_html5_project_report(project_id, report)) + return _html5_response(_html5_project_report_fragment(project_id=project_id, report=report)) @app.get("/html5/projects/{project_id}/review") async def html5_project_review(project_id: str) -> Response: findings = await get_review(project_id) - return _html5_response(render_html5_review(project_id, findings)) + return _html5_response(_html5_project_review_fragment(project_id=project_id, findings=findings)) @app.get("/html5/projects/{project_id}/flowchart") @@ -1847,9 +1813,7 @@ async def html5_project_flowchart( limit: int = 80, ) -> Response: flowchart = await project_flowchart(project_id, focus=focus, depth=depth, limit=limit) - return _html5_response( - render_html5_flowchart(project_id, flowchart, focus=focus, depth=depth), - ) + return _html5_response(_html5_project_flowchart_fragment(project_id=project_id, flowchart=flowchart, focus=focus, depth=depth)) @app.get("/html5/projects/{project_id}/objects/context/{object_name}")