Skip to content

Migrate to generated TypeScript client from ros2_medkit_clients#53

Open
bburda wants to merge 15 commits intomainfrom
feat/generated-client-migration
Open

Migrate to generated TypeScript client from ros2_medkit_clients#53
bburda wants to merge 15 commits intomainfrom
feat/generated-client-migration

Conversation

@bburda
Copy link
Copy Markdown
Contributor

@bburda bburda commented Mar 22, 2026

Pull Request

Summary

Replace the hand-maintained REST client (sovd-api.ts, ~2087 lines) with the generated TypeScript client from @selfpatch/ros2-medkit-client-ts. The Zustand store absorbs all API translation via dispatch helpers and response transforms, keeping the component layer's public interface unchanged.

Key changes:

  • Deleted sovd-api.ts (2087 lines) and its tests
  • Created api-dispatch.ts (524 lines) - entity-type routing for openapi-fetch typed paths
  • Created transforms.ts (362 lines) - response reshaping (field renames, x-medkit extraction)
  • Modified store.ts - swapped SovdApiClient for MedkitClient, migrated SSE fault streaming to SseStream async iterable
  • Updated 14 components - replaced direct client usage with store actions
  • Net result: -636 lines across 33 files

Issue


Type

  • Bug fix
  • New feature
  • Breaking change
  • Documentation only

Testing

  • All 107 tests pass (npm test)
  • TypeScript strict mode passes (npm run typecheck)
  • ESLint passes (npm run lint)
  • Manual verification pending (requires running gateway)

Checklist

  • Breaking changes are clearly described (and announced in docs / changelog if needed)
  • Linting passes (npm run lint)
  • Build succeeds (npm run build)
  • Docs were updated if behavior or public API changed

Copilot AI review requested due to automatic review settings March 22, 2026 21:30
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Migrates the UI from the hand-maintained SovdApiClient to the generated @selfpatch/ros2-medkit-client-ts client, centralizing API path routing and response-shape normalization in the store layer to keep component interfaces stable.

Changes:

  • Replaced the custom REST client with a generated OpenAPI client and added typed dispatch helpers (api-dispatch.ts) for entity-type routing.
  • Introduced pure response mappers (transforms.ts) and corresponding unit tests to reshape gateway responses into UI-friendly types.
  • Updated the Zustand store and multiple components to use store actions instead of direct client calls; removed the old client implementation/tests.

Reviewed changes

Copilot reviewed 25 out of 26 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/lib/utils.ts Adds shared helpers (fault entity type mapping, byte/duration formatting) previously living in the old client.
src/lib/utils.test.ts Adds unit coverage for new utility helpers.
src/lib/types.ts Re-exports select generated-client schema types and keeps manual UI types.
src/lib/transforms.ts Adds response transformation utilities extracted from the old client logic.
src/lib/transforms.test.ts Adds unit tests validating the new transformation behaviors.
src/lib/api-dispatch.ts Adds per-entity-type OpenAPI path dispatch wrappers (apps/components/areas/functions).
src/lib/store.ts Switches to generated client, wires dispatch/transforms, updates fault streaming, and adds component-facing store actions.
src/lib/sovd-api.ts Removes the legacy hand-maintained REST client implementation.
src/lib/sovd-api.test.ts Removes tests for the legacy REST client.
src/components/TopicPublishForm.tsx Switches topic publishing to store action instead of direct client usage.
src/components/DataPanel.tsx Removes client prop dependency and delegates publish behavior to store actions.
src/components/EntityDetailPanel.tsx Updates resource-count and data-loading flow to use store actions.
src/components/EntityResourceTabs.tsx Migrates per-tab resource loads to store actions and store-backed configuration state.
src/components/AppsPanel.tsx Replaces direct client calls with store actions for app resources.
src/components/FunctionsPanel.tsx Replaces direct client calls with store actions for function resources and config counts.
src/components/FaultsPanel.tsx Migrates faults list/detail/clear behaviors to store actions and shared utils mapper.
src/components/FaultsDashboard.tsx Migrates fault detail loading to store action and shared utils mapper.
src/components/RosbagDownloadButton.tsx Switches downloads to store action and moves formatting helpers to utils.
src/components/SnapshotCard.tsx Moves formatting helper imports from old client to utils.
src/components/OperationsPanel.tsx Updates SovdResourceEntityType import location post-migration.
src/components/ConfigurationPanel.tsx Updates SovdResourceEntityType import location post-migration.
src/components/ServerInfoPanel.tsx Switches server info fetches to store actions instead of direct client calls.
package.json Adds generated client dependency (and OpenAPI fetch dependency).
.npmrc Configures scoped registry for @selfpatch packages.

src/lib/store.ts Outdated
Comment on lines +1049 to +1056
const apiPath = selectedPath.replace(/^\/server/, '');
const details = await client.getEntityDetails(apiPath);
set({ selectedEntity: details, isRefreshing: false });
const pathSegments = apiPath.split('/').filter(Boolean);
const entityType = (pathSegments[0] || 'areas') as SovdResourceEntityType;
const entityId = pathSegments[1] || '';
const { data } = await (await import('./api-dispatch')).getEntityDetail(client, entityType, entityId);
if (data) {
set({ selectedEntity: data as unknown as SovdEntityDetails, isRefreshing: false });
} else {
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refreshSelectedEntity() derives entityType/entityId from selectedPath by taking the first two path segments. With the current tree paths (/server/<areaId>/<componentId>), the first segment after /server is an entity ID (not a SovdResourceEntityType), so the refresh will call the wrong endpoint and silently fail. Consider deriving entityType from selectedEntity.type (area/component/app/function) and using selectedEntity.id for the path params, or reusing the same path-parsing logic you use for resource navigation.

Copilot uses AI. Check for mistakes.
src/lib/store.ts Outdated
Comment on lines +1655 to +1674
const { client } = get();
if (!client) return null;
const { getEntityBulkData } = await import('./api-dispatch');
// For binary download, we need the URL to fetch directly with progress
// Build URL from client base and use fetch directly
const { data } = await getEntityBulkData(client, entityType, entityId, category);
if (!data) return null;
// Find the file descriptor to get download info
const items = (data as unknown as { items?: Array<{ id: string; name?: string }> })?.items || [];
const fileDesc = items.find((item) => item.id === fileId);
const filename = fileDesc?.name || fileId;
// Use the generated client for actual download
await import('./api-dispatch');
// Construct the download URL manually since openapi-fetch doesn't support blob responses well
const baseUrl = (client as unknown as { baseUrl?: string }).baseUrl || '';
const downloadUrl = `${baseUrl}/${entityType}/${entityId}/bulk-data/${category}/${fileId}`;
const response = await fetch(downloadUrl);
if (!response.ok) return null;
const blob = await response.blob();
return { blob, filename };
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

downloadBulkData() builds the download URL by reading (client as { baseUrl?: string }).baseUrl and concatenating path segments without encoding. This is brittle (relies on a non-public property that may be undefined) and can produce invalid URLs (double slashes, missing base path, unescaped category/fileId). Prefer using the known serverUrl (and any configured API base path) from store state, ensure each segment is encodeURIComponent’d, and avoid depending on internal client fields. Also consider adding basic error reporting (status/code) instead of returning null on a non-OK response to make failures diagnosable.

Suggested change
const { client } = get();
if (!client) return null;
const { getEntityBulkData } = await import('./api-dispatch');
// For binary download, we need the URL to fetch directly with progress
// Build URL from client base and use fetch directly
const { data } = await getEntityBulkData(client, entityType, entityId, category);
if (!data) return null;
// Find the file descriptor to get download info
const items = (data as unknown as { items?: Array<{ id: string; name?: string }> })?.items || [];
const fileDesc = items.find((item) => item.id === fileId);
const filename = fileDesc?.name || fileId;
// Use the generated client for actual download
await import('./api-dispatch');
// Construct the download URL manually since openapi-fetch doesn't support blob responses well
const baseUrl = (client as unknown as { baseUrl?: string }).baseUrl || '';
const downloadUrl = `${baseUrl}/${entityType}/${entityId}/bulk-data/${category}/${fileId}`;
const response = await fetch(downloadUrl);
if (!response.ok) return null;
const blob = await response.blob();
return { blob, filename };
const { client, serverUrl } = get();
if (!client) return null;
const { getEntityBulkData } = await import('./api-dispatch');
// For binary download, we need the URL to fetch directly with progress
const { data } = await getEntityBulkData(client, entityType, entityId, category);
if (!data) return null;
// Find the file descriptor to get download info
const items = (data as unknown as { items?: Array<{ id: string; name?: string }> })?.items || [];
const fileDesc = items.find((item) => item.id === fileId);
const filename = fileDesc?.name || fileId;
if (!serverUrl) {
toast.error('Server URL is not configured; cannot download file.');
return null;
}
// Construct the download URL manually since openapi-fetch doesn't support blob responses well.
// Use the configured serverUrl and ensure each dynamic segment is URL-encoded.
const normalizedBaseUrl = serverUrl.replace(/\/+$/, '');
const downloadPath = `/${entityType}/${encodeURIComponent(
entityId
)}/bulk-data/${encodeURIComponent(category)}/${encodeURIComponent(fileId)}`;
const downloadUrl = `${normalizedBaseUrl}${downloadPath}`;
try {
const response = await fetch(downloadUrl);
if (!response.ok) {
toast.error(
`Failed to download file (status ${response.status}${
response.statusText ? `: ${response.statusText}` : ''
}).`
);
return null;
}
const blob = await response.blob();
return { blob, filename };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
toast.error(`Failed to download file: ${message}`);
return null;
}

Copilot uses AI. Check for mistakes.
Comment on lines 102 to 105
// Actions
connect: (url: string, baseEndpoint?: string) => Promise<boolean>;
connect: (url: string) => Promise<boolean>;
disconnect: () => void;
setTreeViewMode: (mode: TreeViewMode) => Promise<void>;
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

connect was changed to accept only (url: string), and baseEndpoint was removed from the store state. There are still call sites in the repo that pass a second argument / read baseEndpoint (e.g. App auto-connect and the connection dialog), which will fail type-checking and/or break auto-connect behavior. Either reintroduce baseEndpoint handling here (if still supported) or update all callers and any persisted-state migration to stop relying on it.

Copilot uses AI. Check for mistakes.
src/lib/store.ts Outdated
Comment on lines 308 to 320
const apiPath = path.replace(/^\/server/, '');
const details = await client.getEntityDetails(apiPath);
const pathSegments = apiPath.split('/').filter(Boolean);
const entityType = (pathSegments[0] || 'apps') as SovdResourceEntityType;
const entityId = pathSegments[1] || '';
const { getEntityDetail } = await import('./api-dispatch');
const { data: detailData } = await getEntityDetail(client, entityType, entityId);
const details = (detailData || { id: entityId, name: entityId, type: entityType, href: apiPath }) as SovdEntityDetails;

// Update tree with full data merged with direction info
const updatedTree = updateNodeInTree(rootEntities, path, (n) => ({
...n,
data: { ...details.topicData, isPublisher, isSubscriber },
data: { ...((details as unknown as Record<string, unknown>)?.topicData as Record<string, unknown>), isPublisher, isSubscriber },
}));
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Topic selection is currently fetching entity details via getEntityDetail() using entityType = pathSegments[0] and entityId = pathSegments[1]. With the current tree path scheme (/server/<areaId>/<componentId>/data/<topic>), pathSegments[0] is an area ID, not a SovdResourceEntityType, so this dispatch will hit the wrong endpoint. Also, getEntityDetail() responses won’t contain topicData, so details.topicData will be undefined and the tree update won’t actually enrich the topic node. Consider parsing around the data segment (like the old client did) and calling the data-item endpoint (getEntityDataItem) to populate selectedEntity.topicData (and update the node cache) instead of getEntityDetail().

Copilot uses AI. Check for mistakes.
src/lib/store.ts Outdated
Comment on lines 532 to 539
const apiPath = path.replace(/^\/server/, '');
const details = await client.getEntityDetails(apiPath);
// Parse entity type and id from path: /areas/foo -> areas, foo
const pathSegments = apiPath.split('/').filter(Boolean);
const entityType = (pathSegments[0] || 'areas') as SovdResourceEntityType;
const entityId = pathSegments[1] || '';
const { data } = await (await import('./api-dispatch')).getEntityDetail(client, entityType, entityId);
const details = (data || { id: entityId, name: entityId, type: entityType, href: apiPath }) as SovdEntityDetails;
set({ selectedEntity: details, isLoadingDetails: false });
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchEntityFromApi() assumes paths look like /<entityType>/<entityId> and dispatches via getEntityDetail(entityType, entityId). However, navigation from the detail panel builds resource paths like ${selectedPath}/data/<topic> (e.g. /server/<areaId>/<componentId>/data/<topic>), which won’t match this parsing and will call the wrong endpoint. This will break deep navigation to topics/operations/configurations/faults when those nodes aren’t present in the tree. Consider adding explicit handling for /data/, /operations/, /configurations/, /faults/ paths here (dispatch to the corresponding typed endpoints and transform into SovdEntityDetails), and only fall back to entity detail fetch for true entity paths.

Copilot uses AI. Check for mistakes.
@bburda bburda requested a review from mfaferek93 March 23, 2026 15:19
bburda added 10 commits March 23, 2026 16:20
Add generated TypeScript client and openapi-fetch peer dependency.
Configure .npmrc for GitHub Packages registry.
Create standalone transform functions for fault, operations, data,
configurations, and bulk data responses. These handle x-medkit vendor
extension extraction and field renaming.
Move formatBytes, formatDuration, and mapFaultEntityTypeToResourceType
to utils.ts in preparation for sovd-api.ts removal.
Add import of components schema from @selfpatch/ros2-medkit-client-ts.
Re-export VersionInfo and GenericError (as SovdError) from generated
schema where shapes match. Other types stay manual due to significant
API differences - transforms.ts handles runtime mapping.
Route generic (entityType, entityId) calls to the correct per-entity
typed API paths required by openapi-fetch. Covers configurations,
data, operations, executions, faults, and bulk data.
Replace all SovdApiClient method calls with typed openapi-fetch
GET/POST/PUT/DELETE calls via api-dispatch helpers. Migrate SSE
fault streaming from EventSource to SseStream async iterable.
Remove baseEndpoint from AppState (URL normalization is automatic).
Add fetchEntityData, fetchEntityOperations, listEntityFaults,
getFaultWithEnvironmentData, publishToEntityData, getServerCapabilities,
getVersionInfoAction, downloadBulkData, getFunctionHosts, and
prefetchResourceCounts actions.
…th store actions

Migrate 14 components from direct SovdApiClient usage to store actions:
- Group 1 (import-only): SnapshotCard, ConfigurationPanel, OperationsPanel
- Group 2 (import + client replacement): RosbagDownloadButton, FaultsPanel,
  FaultsDashboard, EntityResourceTabs, AppsPanel, FunctionsPanel, ServerInfoPanel
- Group 3 (remove client prop chain): DataPanel, TopicPublishForm, EntityDetailPanel

All imports updated from @/lib/sovd-api to @/lib/types or @/lib/utils.
All direct client.method() calls replaced with store actions.
Updated RosbagDownloadButton test to mock store action instead of client.
Delete sovd-api.ts (~2087 lines) and its tests. All API calls now
go through the generated @selfpatch/ros2-medkit-client-ts client
via the Zustand store and api-dispatch helpers.
- App.tsx: remove baseEndpoint from store selector and connect() call
- ServerConnectionDialog.tsx: remove base endpoint input field entirely
  (generated client normalizes URLs automatically)
- store.ts: remove unused dynamic import, use store serverUrl instead
  of fragile type cast for download URL construction
@bburda bburda force-pushed the feat/generated-client-migration branch from e1007d4 to c7f8997 Compare March 23, 2026 15:22
bburda added 5 commits March 23, 2026 16:32
The publish UI was showing even when disconnected because the
client null check was dropped during migration. Replaced with
isConnected store check.
…lback fetch

- refreshSelectedEntity: use selectedEntity.type/id instead of path parsing
- handleTopicSelection: find parent entity from tree, fetch via getEntityDataItem
- fetchEntityFromApi: use depth heuristic instead of treating path[0] as entityType
- downloadBulkData: add encodeURIComponent for path segments
- Replace all dynamic imports with static imports
- Add entity type field to raw API responses (areas, components, apps)
- Fix topic detail: transform raw DataItem to ComponentTopic for schema
- Fix SSE fault stream: pass fetch.bind(globalThis) to createMedkitClient
- Fix fetchEntityFromApi: parse /data/ and /operations/ resource paths
- Fix snapshot duplicate React keys in FaultsDashboard and FaultsPanel
- Fix getFaultWithEnvironmentData: single request, no brute-force fallback
- Remove console.log info lines, add console.error on all error paths
- Rename browser tab to ros2_medkit Web UI
- Remove all dynamic imports, use static imports only
- ServerConnectionDialog: title and description
- EmptyState: connection prompt text
- ServerInfoPanel: fallback server name
- store.ts: fallback in loadRootEntities
- Fix auto-connect loop in React strict mode
- Add @testing-library/dom to fix peer dep resolution
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Migrate to generated TypeScript client from ros2_medkit_clients

2 participants