FlowFuse / FlowFuse/flowfuse

Typescript: Phase 3 — API Modules

Open
#7,216 0 comments 0 reactions 1 assignee Claimed by @n-lark View on GitHub
Dominant language
JavaScript
Stars
400
Forks
89
Avg merge
1d 21h
Merged PRs (30d)
146

Description

## Phase 3 — API Modules

**Prerequisite:** `frontend/src/types/generated.ts` is regenerated as part of the response-schema tightening PR see https://github.com/FlowFuse/flowfuse/pull/7131. Phase 3 should start against the post-tightening types — they drop most `?.` chains (required fields) and `[key: string]: unknown` indexers (closed shapes), and introduce a few `anyOf` union return types that need narrowing at call sites.

> `frontend/src/api/*.js` — 28 files. Each module is a thin wrapper around the shared axios `client` from `client.js`, typically returning `client.(url).then(res => res.data)` (sometimes after mutating `res.data` to splice in derived fields). LLM-assisted: feed file + types, review diff, merge.

Suggested order (highest impact first): `client.js` (foundation) → `team.js` → `devices.js` → `application.js` → `instances.js` → `user.js` → remainder.

**Per file:**
- [ ] Rename `.js` → `.ts`
- [ ] Drop `.js` extensions from sibling/cross-package imports (`'./client.js'` → `'./client'`, `'@/api/foo.js'` → `'@/api/foo'`) — TS resolution stops finding the `.js` once the file is renamed
- [ ] Use `client.get(url)` / `client.post(url, body)` generics so `res.data` is typed without manual casts
- [ ] Annotate exported function parameters and return types using domain types from `@/types`
- [ ] If a function enriches `res.data` before returning (`device.lastSeenSince`, `team.link`, `r.roleName`), declare a view-model type (`Device & { lastSeenSince: string }`) — the wire shape is **not** the returned shape
- [ ] For `anyOf` responses (`Team | TeamSummary`, `MQTTBroker | { state: 'suspended' }`), let the union flow out — narrowing is the caller's job
- [ ] For POST/PUT/import endpoints, type the request body separately from the response (PR2 inlined some bodies — see the schema-audit table above)
- [ ] Run `vue-tsc --noEmit` from `frontend/` — no new errors
- [ ] Rename `.spec.js` → `.spec.ts` alongside each converted source file (Vitest is already TS-native)

**Per phase**:

- [ ] Enable `strict: true` per-directory as each Phase 3 directory is fully converted

#### Convert `client.js` first

Everything else builds on it. Once the default export is typed as `AxiosInstance`, the per-method generics light up across all 27 other modules.

```ts
import axios, { type AxiosInstance } from 'axios'

import Alerts from '@/services/alerts'
import { useAccountAuthStore } from '@/stores/account-auth'
import { useUxLoadingStore } from '@/stores/ux-loading'

const client: AxiosInstance = axios.create({ /* ... */ })

client.interceptors.response.use(
(response) => response,
async (error) => { /* existing 401/500/network handling */ return Promise.reject(error) }
)

export default client
```

#### How to type API methods

```ts
import type { Team, TeamSummary, Device, FlowBlueprint, FlowBlueprintCreate } from '@/types'
import client from './client'

// 1. Plain GET — `res.data` is typed by the generic
const getTeam = async (teamId: string): Promise => {
const res = await client.get(`/api/v1/teams/${teamId}`)
return res.data
}

// 2. anyOf union — declare it on the generic, return it as-is
const getTeamBySlug = async (slug: string): Promise => {
const res = await client.get(`/api/v1/teams/slug/${slug}`)
return res.data
}

// 3. Mutated/enriched return — view-model intersection captures the extra fields
type DeviceWithDerived = Device & { lastSeenSince: string }

const getDevices = async (): Promise<{ devices: DeviceWithDerived[] }> => {
const res = await client.get<{ devices: Device[] }>('/api/v1/devices')
const devices = res.data.devices.map((d) => ({
...d,
lastSeenSince: d.lastSeenAt ? elapsedTime(0, d.lastSeenMs) + ' ago' : ''
}))
return { ...res.data, devices }
}

// 4. POST with distinct request-body type (PR2 inlined these for flowBlueprints / 3rdPartyBroker)
const createBlueprint = async (body: FlowBlueprintCreate): Promise => {
const res = await client.post('/api/v1/flow-blueprints', body)
return res.data
}
```

If a generated request-body shape isn't already exposed in `@/types`, add a re-export there rather than reaching into `generated.ts` from API files — keeps `'@/types'` as the single import surface.

#### Common gotchas

- **Polymorphic parameters:** `getTeam(team)` accepts a string OR an object with `.slug`. Type as a union (`team: string | { slug: string }`) and narrow inside the function with `typeof team === 'object'`. Don't change the callsite contract during conversion — that's a separate PR.
- **Mutation-before-return is the norm, not the exception:** API methods routinely splice derived fields (`device.lastSeenSince`, `r.link`, `r.roleName`) onto `res.data`. Use an intersection view-model. Resist the urge to refactor the enrichment out into a composable mid-conversion — keep the diff mechanical.
- **Cross-file imports of unconverted utilities:** `paginateUrl`, `daysSince`, `elapsedTime` are still `.js` early in Phase 3. Their imports will be implicit-`any` returns until converted — that's fine, don't block on it.
- **Default-exported method bag:** most modules end with `export default { getTeam, getTeams, ... }`. Preserve the shape — TS infers the object type from the function declarations; consumers don't need a separate interface.
- **`product.capture` / `product.groupUpdate` side effects:** PostHog calls don't affect return types. Leave them.
- **Errors don't show in return types:** the response interceptor in `client.js` re-rejects on 401/500/network — API methods themselves rarely `throw`. The resolved type is what shows up at the callsite. Don't widen returns to `T | Error`.
- **Don't sweep unconverted siblings:** when converting `team.ts`, only rewrite imports inside that file. Other still-`.js` modules keep their `.js` suffixes until their own conversion PR.
- **Re-read the schema-audit table:** the per-file callouts above (`team.js` union, `flowBlueprints.js` body/response split, `expert.js` `additionalProperties: true` retention, snapshot inline user shape) are load-bearing. Skim them before each module.

| # | File | Schema-audit notes |
|---|---|---|
| 1 | `team.js` | GET `/:teamId` + `/slug/:teamSlug` return `Team \| TeamSummary` — union return |
| 2 | `devices.js` | Embedded `application` is `ApplicationSummary`; `Device.statusOnly` may have split shape |
| 3 | `application.js` | — |
| 4 | `instances.js` | PUT `/projects/:id` import/start early-return schema is in PR2 deferred list |
| 5 | `user.js` | — |
| 6 | `admin.js` | — |
| 7 | `assets.js` | — |
| 8 | `billing.js` | — |
| 9 | `broker.js` | Team-broker GET `/:brokerId` returns `MQTTBroker \| { state: 'suspended' }` |
| 10 | `client.js` | Axios shim — types here propagate everywhere; type carefully |
| 11 | `expert.js` | `/mcp/features` keeps `additionalProperties: true` deliberately |
| 12 | `external.js` | — |
| 13 | `flowBlueprints.js` | POST/PUT/import bodies are inline schemas — type separately from `FlowBlueprint` response |
| 14 | `global.js` | — |
| 15 | `instanceTypes.js` | — |
| 16 | `pipeline.js` | — |
| 17 | `projectSnapshots.js` | Inline narrow user shape; `device` is `DeviceSummary` |
| 18 | `search.js` | — |
| 19 | `settings.js` | — |
| 20 | `snapshots.js` | Inline narrow user shape; `device` is `DeviceSummary` |
| 21 | `sso.js` | — |
| 22 | `stacks.js` | — |
| 23 | `tables.js` | — |
| 24 | `teamTypes.js` | — |
| 25 | `teams.js` | — |
| 26 | `templates.js` | — |
| 27 | `users.js` | `Invitation` no longer spreads `UserSummary` at root |
| 28 | `versionHistory.js` | — |

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.