refactor: remove XML export, JSON-only for .rfflow files

- Remove XML builder, format query param, and XML tests
- Simplify ExportFlowModal (no format picker)
- Simplify rfflowParser (JSON-only)
- Remove format field from schemas and types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-03-06 00:56:07 -05:00
parent 39677a3841
commit 88c1553c5d
8 changed files with 22 additions and 293 deletions

View File

@@ -1,8 +1,6 @@
"""Flow export/import endpoints (.rfflow files).""" """Flow export/import endpoints (.rfflow files)."""
import json
import logging import logging
import re import re
import xml.etree.ElementTree as ET
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Annotated, Optional from typing import Annotated, Optional
from uuid import UUID from uuid import UUID
@@ -43,45 +41,6 @@ def _slugify(name: str) -> str:
return re.sub(r'[-\s]+', '-', slug) return re.sub(r'[-\s]+', '-', slug)
def _build_xml(envelope: FlowExportEnvelope) -> str:
"""Build XML representation of an .rfflow export."""
root = ET.Element("rfflow")
root.set("version", envelope.rfflow_version)
ET.SubElement(root, "exported_at").text = envelope.exported_at.isoformat()
ET.SubElement(root, "source_app").text = envelope.source_app
ET.SubElement(root, "format").text = "xml"
flow_el = ET.SubElement(root, "flow")
flow = envelope.flow
ET.SubElement(flow_el, "name").text = flow.name
ET.SubElement(flow_el, "description").text = flow.description or ""
ET.SubElement(flow_el, "tree_type").text = flow.tree_type
ET.SubElement(flow_el, "version").text = str(flow.version)
ET.SubElement(flow_el, "author_name").text = flow.author_name or ""
if flow.category:
cat_el = ET.SubElement(flow_el, "category")
ET.SubElement(cat_el, "name").text = flow.category.name
ET.SubElement(cat_el, "slug").text = flow.category.slug
tags_el = ET.SubElement(flow_el, "tags")
for tag in flow.tags:
ET.SubElement(tags_el, "tag").text = tag
# Store tree_structure as JSON string in CDATA-safe text
ts_el = ET.SubElement(flow_el, "tree_structure")
ts_el.text = json.dumps(flow.tree_structure)
if flow.intake_form:
if_el = ET.SubElement(flow_el, "intake_form")
if_el.text = json.dumps(flow.intake_form)
ET.indent(root)
return ET.tostring(root, encoding="unicode", xml_declaration=True)
# --- Export --- # --- Export ---
@router.get("/{tree_id}/export") @router.get("/{tree_id}/export")
@@ -89,9 +48,8 @@ async def export_tree(
tree_id: UUID, tree_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)], current_user: Annotated[User, Depends(get_current_active_user)],
format: str = Query("json", pattern="^(json|xml)$"),
): ):
"""Export a tree as a downloadable .rfflow file (JSON or XML).""" """Export a tree as a downloadable .rfflow JSON file."""
# Load tree with relationships + author name # Load tree with relationships + author name
result = await db.execute( result = await db.execute(
select(Tree) select(Tree)
@@ -139,25 +97,15 @@ async def export_tree(
rfflow_version="1.0", rfflow_version="1.0",
exported_at=datetime.now(timezone.utc), exported_at=datetime.now(timezone.utc),
source_app="ResolutionFlow", source_app="ResolutionFlow",
format=format,
flow=flow_data, flow=flow_data,
) )
slug = _slugify(tree.name) slug = _slugify(tree.name)
# Audit log # Audit log
await log_audit(db, current_user.id, "tree.export", "tree", tree.id, {"format": format}) await log_audit(db, current_user.id, "tree.export", "tree", tree.id)
await db.commit() await db.commit()
if format == "xml":
content = _build_xml(envelope)
return Response(
content=content,
media_type="application/xml",
headers={"Content-Disposition": f'attachment; filename="{slug}.rfflow"'},
)
# JSON
content = envelope.model_dump_json(indent=2) content = envelope.model_dump_json(indent=2)
return Response( return Response(
content=content, content=content,

View File

@@ -1,6 +1,6 @@
"""Schemas for .rfflow file export and import.""" """Schemas for .rfflow file export and import."""
from datetime import datetime from datetime import datetime
from typing import Optional, Any, Literal from typing import Optional, Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.schemas.tree import TreeType from app.schemas.tree import TreeType
@@ -30,7 +30,6 @@ class FlowExportEnvelope(BaseModel):
rfflow_version: str = "1.0" rfflow_version: str = "1.0"
exported_at: datetime exported_at: datetime
source_app: str = "ResolutionFlow" source_app: str = "ResolutionFlow"
format: Literal["json", "xml"] = "json"
flow: FlowExportData flow: FlowExportData
@@ -39,7 +38,6 @@ class FlowImportRequest(BaseModel):
rfflow_version: str = Field(..., description="Must be '1.0'") rfflow_version: str = Field(..., description="Must be '1.0'")
exported_at: datetime exported_at: datetime
source_app: str = "ResolutionFlow" source_app: str = "ResolutionFlow"
format: Literal["json", "xml"] = "json"
flow: FlowExportData flow: FlowExportData

View File

@@ -1,6 +1,4 @@
"""Tests for flow export/import (.rfflow) endpoints.""" """Tests for flow export/import (.rfflow) endpoints."""
import json
import xml.etree.ElementTree as ET
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
@@ -41,7 +39,7 @@ async def create_tree_with_tags(client: AsyncClient, headers: dict, data: dict |
async def test_export_json_format(client, auth_headers, test_tree): async def test_export_json_format(client, auth_headers, test_tree):
"""Export should return valid .rfflow JSON with correct structure.""" """Export should return valid .rfflow JSON with correct structure."""
resp = await client.get( resp = await client.get(
f"/api/v1/trees/{test_tree['id']}/export?format=json", f"/api/v1/trees/{test_tree['id']}/export",
headers=auth_headers, headers=auth_headers,
) )
assert resp.status_code == 200 assert resp.status_code == 200
@@ -51,7 +49,6 @@ async def test_export_json_format(client, auth_headers, test_tree):
data = resp.json() data = resp.json()
assert data["rfflow_version"] == "1.0" assert data["rfflow_version"] == "1.0"
assert data["source_app"] == "ResolutionFlow" assert data["source_app"] == "ResolutionFlow"
assert data["format"] == "json"
assert data["exported_at"] is not None assert data["exported_at"] is not None
flow = data["flow"] flow = data["flow"]
@@ -65,37 +62,12 @@ async def test_export_json_format(client, auth_headers, test_tree):
assert "account_id" not in flow assert "account_id" not in flow
@pytest.mark.asyncio
async def test_export_xml_format(client, auth_headers, test_tree):
"""Export as XML should be parseable and contain all required fields."""
resp = await client.get(
f"/api/v1/trees/{test_tree['id']}/export?format=xml",
headers=auth_headers,
)
assert resp.status_code == 200
assert ".rfflow" in resp.headers.get("content-disposition", "")
# Parse XML
root = ET.fromstring(resp.text)
assert root.tag == "rfflow"
assert root.get("version") == "1.0"
flow_el = root.find("flow")
assert flow_el is not None
assert flow_el.find("name").text == test_tree["name"]
# tree_structure should be valid JSON
ts_text = flow_el.find("tree_structure").text
ts = json.loads(ts_text)
assert ts["id"] == "root"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_export_with_category_and_tags(client, auth_headers): async def test_export_with_category_and_tags(client, auth_headers):
"""Export should include category and tag data.""" """Export should include category and tag data."""
tree = await create_tree_with_tags(client, auth_headers) tree = await create_tree_with_tags(client, auth_headers)
resp = await client.get( resp = await client.get(
f"/api/v1/trees/{tree['id']}/export?format=json", f"/api/v1/trees/{tree['id']}/export",
headers=auth_headers, headers=auth_headers,
) )
assert resp.status_code == 200 assert resp.status_code == 200
@@ -121,9 +93,8 @@ async def test_export_access_control(client, auth_headers, test_admin, admin_aut
}) })
other_headers = {"Authorization": f"Bearer {login_resp.json()['access_token']}"} other_headers = {"Authorization": f"Bearer {login_resp.json()['access_token']}"}
# The other user is in a different account, so can't see the tree
resp = await client.get( resp = await client.get(
f"/api/v1/trees/{test_tree['id']}/export?format=json", f"/api/v1/trees/{test_tree['id']}/export",
headers=other_headers, headers=other_headers,
) )
assert resp.status_code == 403 assert resp.status_code == 403
@@ -134,7 +105,7 @@ async def test_export_nonexistent_tree(client, auth_headers):
"""Export of non-existent tree returns 404.""" """Export of non-existent tree returns 404."""
import uuid import uuid
resp = await client.get( resp = await client.get(
f"/api/v1/trees/{uuid.uuid4()}/export?format=json", f"/api/v1/trees/{uuid.uuid4()}/export",
headers=auth_headers, headers=auth_headers,
) )
assert resp.status_code == 404 assert resp.status_code == 404
@@ -147,7 +118,7 @@ async def test_import_happy_path(client, auth_headers, test_tree):
"""Import should create a draft tree owned by the importing user.""" """Import should create a draft tree owned by the importing user."""
# First export # First export
export_resp = await client.get( export_resp = await client.get(
f"/api/v1/trees/{test_tree['id']}/export?format=json", f"/api/v1/trees/{test_tree['id']}/export",
headers=auth_headers, headers=auth_headers,
) )
rfflow_data = export_resp.json() rfflow_data = export_resp.json()
@@ -180,7 +151,7 @@ async def test_import_happy_path(client, auth_headers, test_tree):
async def test_import_with_name_override(client, auth_headers, test_tree): async def test_import_with_name_override(client, auth_headers, test_tree):
"""Import with name_override should use the override name.""" """Import with name_override should use the override name."""
export_resp = await client.get( export_resp = await client.get(
f"/api/v1/trees/{test_tree['id']}/export?format=json", f"/api/v1/trees/{test_tree['id']}/export",
headers=auth_headers, headers=auth_headers,
) )
rfflow_data = export_resp.json() rfflow_data = export_resp.json()
@@ -201,7 +172,6 @@ async def test_import_with_new_tags(client, auth_headers):
"rfflow_version": "1.0", "rfflow_version": "1.0",
"exported_at": "2026-03-05T14:30:00+00:00", "exported_at": "2026-03-05T14:30:00+00:00",
"source_app": "ResolutionFlow", "source_app": "ResolutionFlow",
"format": "json",
"flow": { "flow": {
"name": "Test Import Tags", "name": "Test Import Tags",
"description": "Testing tag creation", "description": "Testing tag creation",
@@ -235,7 +205,6 @@ async def test_import_with_category_creation(client, auth_headers):
"rfflow_version": "1.0", "rfflow_version": "1.0",
"exported_at": "2026-03-05T14:30:00+00:00", "exported_at": "2026-03-05T14:30:00+00:00",
"source_app": "ResolutionFlow", "source_app": "ResolutionFlow",
"format": "json",
"flow": { "flow": {
"name": "Import Category Test", "name": "Import Category Test",
"description": None, "description": None,
@@ -267,7 +236,6 @@ async def test_import_invalid_version(client, auth_headers):
"rfflow_version": "99.0", "rfflow_version": "99.0",
"exported_at": "2026-03-05T14:30:00+00:00", "exported_at": "2026-03-05T14:30:00+00:00",
"source_app": "ResolutionFlow", "source_app": "ResolutionFlow",
"format": "json",
"flow": { "flow": {
"name": "Bad Version", "name": "Bad Version",
"tree_type": "troubleshooting", "tree_type": "troubleshooting",
@@ -288,7 +256,7 @@ async def test_import_round_trip(client, auth_headers):
# Export # Export
export_resp = await client.get( export_resp = await client.get(
f"/api/v1/trees/{original['id']}/export?format=json", f"/api/v1/trees/{original['id']}/export",
headers=auth_headers, headers=auth_headers,
) )
rfflow = export_resp.json() rfflow = export_resp.json()
@@ -312,53 +280,3 @@ async def test_import_round_trip(client, auth_headers):
assert imported_tree["tree_structure"]["id"] == original["tree_structure"]["id"] assert imported_tree["tree_structure"]["id"] == original["tree_structure"]["id"]
assert imported_tree["tree_type"] == original["tree_type"] assert imported_tree["tree_type"] == original["tree_type"]
assert imported_tree["status"] == "draft" # Always draft on import assert imported_tree["status"] == "draft" # Always draft on import
@pytest.mark.asyncio
async def test_import_xml_round_trip(client, auth_headers):
"""Export as XML then re-import the parsed structure should work."""
original = await create_tree_with_tags(client, auth_headers)
# Export as XML
export_resp = await client.get(
f"/api/v1/trees/{original['id']}/export?format=xml",
headers=auth_headers,
)
assert export_resp.status_code == 200
# Parse XML to build import request (simulating frontend parser)
root = ET.fromstring(export_resp.text)
flow_el = root.find("flow")
tags = [t.text for t in flow_el.find("tags").findall("tag")]
ts = json.loads(flow_el.find("tree_structure").text)
cat_el = flow_el.find("category")
category = None
if cat_el is not None:
cat_name = cat_el.find("name")
cat_slug = cat_el.find("slug")
if cat_name is not None and cat_slug is not None:
category = {"name": cat_name.text, "slug": cat_slug.text}
rfflow = {
"rfflow_version": root.get("version"),
"exported_at": root.find("exported_at").text,
"source_app": root.find("source_app").text,
"format": "xml",
"flow": {
"name": flow_el.find("name").text,
"description": flow_el.find("description").text or None,
"tree_type": flow_el.find("tree_type").text,
"version": int(flow_el.find("version").text),
"author_name": flow_el.find("author_name").text or None,
"category": category,
"tags": tags,
"tree_structure": ts,
"intake_form": None,
},
}
import_resp = await client.post("/api/v1/trees/import", json=rfflow, headers=auth_headers)
assert import_resp.status_code == 201
result = import_resp.json()
assert result["name"] == original["name"]

View File

@@ -2,9 +2,8 @@ import apiClient from './client'
import type { RFFlowFile, FlowImportResponse } from '@/types' import type { RFFlowFile, FlowImportResponse } from '@/types'
export const flowTransferApi = { export const flowTransferApi = {
async exportFlow(treeId: string, format: 'json' | 'xml' = 'json'): Promise<Blob> { async exportFlow(treeId: string): Promise<Blob> {
const response = await apiClient.get(`/trees/${treeId}/export`, { const response = await apiClient.get(`/trees/${treeId}/export`, {
params: { format },
responseType: 'blob', responseType: 'blob',
}) })
return response.data return response.data

View File

@@ -2,7 +2,6 @@ import { useState, useEffect } from 'react'
import { Download, X } from 'lucide-react' import { Download, X } from 'lucide-react'
import { flowTransferApi } from '@/api/flowTransfer' import { flowTransferApi } from '@/api/flowTransfer'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
interface ExportFlowModalProps { interface ExportFlowModalProps {
treeId: string treeId: string
@@ -15,7 +14,6 @@ function slugify(name: string): string {
} }
export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalProps) { export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalProps) {
const [format, setFormat] = useState<'json' | 'xml'>('json')
const [isExporting, setIsExporting] = useState(false) const [isExporting, setIsExporting] = useState(false)
useEffect(() => { useEffect(() => {
@@ -29,7 +27,7 @@ export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalPr
const handleExport = async () => { const handleExport = async () => {
setIsExporting(true) setIsExporting(true)
try { try {
const blob = await flowTransferApi.exportFlow(treeId, format) const blob = await flowTransferApi.exportFlow(treeId)
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
@@ -73,40 +71,10 @@ export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalPr
</div> </div>
{/* Body */} {/* Body */}
<div className="space-y-4 px-5 py-4"> <div className="px-5 py-4">
<p className="text-xs text-muted-foreground"> <p className="text-sm text-muted-foreground">
Export <span className="font-medium text-foreground">{treeName}</span> as an .rfflow file. Export <span className="font-medium text-foreground">{treeName}</span> as a <code className="text-xs font-label">.rfflow</code> file (JSON format).
</p> </p>
<fieldset className="space-y-2">
<legend className="mb-1.5 text-xs font-medium text-muted-foreground">Format</legend>
{(['json', 'xml'] as const).map((fmt) => (
<label
key={fmt}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 transition-colors',
format === fmt
? 'border-primary/40 bg-primary/5'
: 'border-border hover:border-[rgba(255,255,255,0.12)]'
)}
>
<input
type="radio"
name="export-format"
value={fmt}
checked={format === fmt}
onChange={() => setFormat(fmt)}
className="accent-primary"
/>
<div>
<span className="text-sm font-medium text-foreground">{fmt.toUpperCase()}</span>
<p className="text-xs text-muted-foreground">
{fmt === 'json' ? 'Recommended — standard format' : 'XML with JSON tree structure'}
</p>
</div>
</label>
))}
</fieldset>
</div> </div>
{/* Footer */} {/* Footer */}

View File

@@ -132,7 +132,7 @@ export function ImportFlowModal({ onClose }: ImportFlowModalProps) {
<p className="text-sm text-foreground"> <p className="text-sm text-foreground">
Drop .rfflow file here or <span className="text-primary cursor-pointer">browse</span> Drop .rfflow file here or <span className="text-primary cursor-pointer">browse</span>
</p> </p>
<p className="mt-1 text-xs text-muted-foreground">JSON or XML format</p> <p className="mt-1 text-xs text-muted-foreground">JSON format</p>
</div> </div>
<input <input
ref={fileInputRef} ref={fileInputRef}

View File

@@ -1,4 +1,4 @@
import type { RFFlowFile, FlowExportData, FlowExportCategory } from '@/types' import type { RFFlowFile, FlowExportData } from '@/types'
export class RFFlowParseError extends Error { export class RFFlowParseError extends Error {
constructor(message: string) { constructor(message: string) {
@@ -10,20 +10,12 @@ export class RFFlowParseError extends Error {
export function parseRFFlowFile(content: string): RFFlowFile { export function parseRFFlowFile(content: string): RFFlowFile {
const trimmed = content.trim() const trimmed = content.trim()
if (trimmed.startsWith('{')) { if (!trimmed.startsWith('{')) {
return parseJSON(trimmed) throw new RFFlowParseError('Invalid file format. Expected JSON.')
} }
if (trimmed.startsWith('<?xml') || trimmed.startsWith('<rfflow')) {
return parseXML(trimmed)
}
throw new RFFlowParseError('Unrecognized file format. Expected JSON or XML.')
}
function parseJSON(content: string): RFFlowFile {
try { try {
const data = JSON.parse(content) const data = JSON.parse(trimmed)
validateEnvelope(data) validateEnvelope(data)
return data as RFFlowFile return data as RFFlowFile
} catch (err) { } catch (err) {
@@ -32,99 +24,6 @@ function parseJSON(content: string): RFFlowFile {
} }
} }
function parseXML(content: string): RFFlowFile {
const parser = new DOMParser()
const doc = parser.parseFromString(content, 'application/xml')
const parseError = doc.querySelector('parsererror')
if (parseError) {
throw new RFFlowParseError('Invalid XML file')
}
const root = doc.documentElement
if (root.tagName !== 'rfflow') {
throw new RFFlowParseError('Root element must be <rfflow>')
}
const getText = (parent: Element, tag: string): string => {
const el = parent.querySelector(`:scope > ${tag}`)
return el?.textContent?.trim() ?? ''
}
const rfflowVersion = root.getAttribute('version') || getText(root, 'rfflow_version') || '1.0'
const exportedAt = getText(root, 'exported_at')
const sourceApp = getText(root, 'source_app') || 'ResolutionFlow'
const flowEl = root.querySelector(':scope > flow')
if (!flowEl) {
throw new RFFlowParseError('Missing <flow> element')
}
// Parse category
let category: FlowExportCategory | null = null
const catEl = flowEl.querySelector(':scope > category')
if (catEl) {
const catName = getText(catEl, 'name')
const catSlug = getText(catEl, 'slug')
if (catName && catSlug) {
category = { name: catName, slug: catSlug }
}
}
// Parse tags
const tags: string[] = []
const tagsEl = flowEl.querySelector(':scope > tags')
if (tagsEl) {
tagsEl.querySelectorAll(':scope > tag').forEach((tagEl) => {
const val = tagEl.textContent?.trim()
if (val) tags.push(val)
})
}
// Parse tree_structure (stored as JSON string)
const tsText = getText(flowEl, 'tree_structure')
let treeStructure: Record<string, unknown>
try {
treeStructure = JSON.parse(tsText)
} catch {
throw new RFFlowParseError('Invalid tree_structure JSON in XML')
}
// Parse intake_form (optional, stored as JSON string)
let intakeForm: Record<string, unknown>[] | null = null
const ifText = getText(flowEl, 'intake_form')
if (ifText) {
try {
intakeForm = JSON.parse(ifText)
} catch {
throw new RFFlowParseError('Invalid intake_form JSON in XML')
}
}
const flow: FlowExportData = {
name: getText(flowEl, 'name'),
description: getText(flowEl, 'description') || null,
tree_type: getText(flowEl, 'tree_type') as FlowExportData['tree_type'],
version: parseInt(getText(flowEl, 'version') || '1', 10),
author_name: getText(flowEl, 'author_name') || null,
category,
tags,
tree_structure: treeStructure,
intake_form: intakeForm,
}
const result: RFFlowFile = {
rfflow_version: rfflowVersion,
exported_at: exportedAt,
source_app: sourceApp,
format: 'xml',
flow,
}
validateEnvelope(result)
return result
}
function validateEnvelope(data: unknown): asserts data is RFFlowFile { function validateEnvelope(data: unknown): asserts data is RFFlowFile {
const obj = data as Record<string, unknown> const obj = data as Record<string, unknown>
@@ -146,8 +45,8 @@ function validateEnvelope(data: unknown): asserts data is RFFlowFile {
throw new RFFlowParseError('Flow must have a tree_structure') throw new RFFlowParseError('Flow must have a tree_structure')
} }
const validTypes = ['troubleshooting', 'procedural', 'maintenance'] const validTypes: FlowExportData['tree_type'][] = ['troubleshooting', 'procedural', 'maintenance']
if (!validTypes.includes(flow.tree_type as string)) { if (!validTypes.includes(flow.tree_type as FlowExportData['tree_type'])) {
throw new RFFlowParseError(`Invalid tree_type: ${flow.tree_type}`) throw new RFFlowParseError(`Invalid tree_type: ${flow.tree_type}`)
} }
} }

View File

@@ -19,7 +19,6 @@ export interface RFFlowFile {
rfflow_version: string rfflow_version: string
exported_at: string exported_at: string
source_app: string source_app: string
format: 'json' | 'xml'
flow: FlowExportData flow: FlowExportData
} }