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)."""
import json
import logging
import re
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from typing import Annotated, Optional
from uuid import UUID
@@ -43,45 +41,6 @@ def _slugify(name: str) -> str:
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 ---
@router.get("/{tree_id}/export")
@@ -89,9 +48,8 @@ async def export_tree(
tree_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
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
result = await db.execute(
select(Tree)
@@ -139,25 +97,15 @@ async def export_tree(
rfflow_version="1.0",
exported_at=datetime.now(timezone.utc),
source_app="ResolutionFlow",
format=format,
flow=flow_data,
)
slug = _slugify(tree.name)
# 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()
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)
return Response(
content=content,

View File

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

View File

@@ -1,6 +1,4 @@
"""Tests for flow export/import (.rfflow) endpoints."""
import json
import xml.etree.ElementTree as ET
import pytest
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):
"""Export should return valid .rfflow JSON with correct structure."""
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,
)
assert resp.status_code == 200
@@ -51,7 +49,6 @@ async def test_export_json_format(client, auth_headers, test_tree):
data = resp.json()
assert data["rfflow_version"] == "1.0"
assert data["source_app"] == "ResolutionFlow"
assert data["format"] == "json"
assert data["exported_at"] is not None
flow = data["flow"]
@@ -65,37 +62,12 @@ async def test_export_json_format(client, auth_headers, test_tree):
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
async def test_export_with_category_and_tags(client, auth_headers):
"""Export should include category and tag data."""
tree = await create_tree_with_tags(client, auth_headers)
resp = await client.get(
f"/api/v1/trees/{tree['id']}/export?format=json",
f"/api/v1/trees/{tree['id']}/export",
headers=auth_headers,
)
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']}"}
# The other user is in a different account, so can't see the tree
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,
)
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."""
import uuid
resp = await client.get(
f"/api/v1/trees/{uuid.uuid4()}/export?format=json",
f"/api/v1/trees/{uuid.uuid4()}/export",
headers=auth_headers,
)
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."""
# First export
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,
)
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):
"""Import with name_override should use the override name."""
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,
)
rfflow_data = export_resp.json()
@@ -201,7 +172,6 @@ async def test_import_with_new_tags(client, auth_headers):
"rfflow_version": "1.0",
"exported_at": "2026-03-05T14:30:00+00:00",
"source_app": "ResolutionFlow",
"format": "json",
"flow": {
"name": "Test Import Tags",
"description": "Testing tag creation",
@@ -235,7 +205,6 @@ async def test_import_with_category_creation(client, auth_headers):
"rfflow_version": "1.0",
"exported_at": "2026-03-05T14:30:00+00:00",
"source_app": "ResolutionFlow",
"format": "json",
"flow": {
"name": "Import Category Test",
"description": None,
@@ -267,7 +236,6 @@ async def test_import_invalid_version(client, auth_headers):
"rfflow_version": "99.0",
"exported_at": "2026-03-05T14:30:00+00:00",
"source_app": "ResolutionFlow",
"format": "json",
"flow": {
"name": "Bad Version",
"tree_type": "troubleshooting",
@@ -288,7 +256,7 @@ async def test_import_round_trip(client, auth_headers):
# Export
export_resp = await client.get(
f"/api/v1/trees/{original['id']}/export?format=json",
f"/api/v1/trees/{original['id']}/export",
headers=auth_headers,
)
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_type"] == original["tree_type"]
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'
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`, {
params: { format },
responseType: 'blob',
})
return response.data

View File

@@ -2,7 +2,6 @@ import { useState, useEffect } from 'react'
import { Download, X } from 'lucide-react'
import { flowTransferApi } from '@/api/flowTransfer'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
interface ExportFlowModalProps {
treeId: string
@@ -15,7 +14,6 @@ function slugify(name: string): string {
}
export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalProps) {
const [format, setFormat] = useState<'json' | 'xml'>('json')
const [isExporting, setIsExporting] = useState(false)
useEffect(() => {
@@ -29,7 +27,7 @@ export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalPr
const handleExport = async () => {
setIsExporting(true)
try {
const blob = await flowTransferApi.exportFlow(treeId, format)
const blob = await flowTransferApi.exportFlow(treeId)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
@@ -73,40 +71,10 @@ export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalPr
</div>
{/* Body */}
<div className="space-y-4 px-5 py-4">
<p className="text-xs text-muted-foreground">
Export <span className="font-medium text-foreground">{treeName}</span> as an .rfflow file.
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">
Export <span className="font-medium text-foreground">{treeName}</span> as a <code className="text-xs font-label">.rfflow</code> file (JSON format).
</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>
{/* Footer */}

View File

@@ -132,7 +132,7 @@ export function ImportFlowModal({ onClose }: ImportFlowModalProps) {
<p className="text-sm text-foreground">
Drop .rfflow file here or <span className="text-primary cursor-pointer">browse</span>
</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>
<input
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 {
constructor(message: string) {
@@ -10,20 +10,12 @@ export class RFFlowParseError extends Error {
export function parseRFFlowFile(content: string): RFFlowFile {
const trimmed = content.trim()
if (trimmed.startsWith('{')) {
return parseJSON(trimmed)
if (!trimmed.startsWith('{')) {
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 {
const data = JSON.parse(content)
const data = JSON.parse(trimmed)
validateEnvelope(data)
return data as RFFlowFile
} 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 {
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')
}
const validTypes = ['troubleshooting', 'procedural', 'maintenance']
if (!validTypes.includes(flow.tree_type as string)) {
const validTypes: FlowExportData['tree_type'][] = ['troubleshooting', 'procedural', 'maintenance']
if (!validTypes.includes(flow.tree_type as FlowExportData['tree_type'])) {
throw new RFFlowParseError(`Invalid tree_type: ${flow.tree_type}`)
}
}

View File

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