Skip to content

[full-ci] chore: [OCISDEV-215] replace axios with the native fetch API in Web - #12910

Open
mzner wants to merge 18 commits into
masterfrom
chore/OCISDEV-215/replace-axios-with-native-fetch
Open

[full-ci] chore: [OCISDEV-215] replace axios with the native fetch API in Web#12910
mzner wants to merge 18 commits into
masterfrom
chore/OCISDEV-215/replace-axios-with-native-fetch

Conversation

@mzner

@mzner mzner commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Description

The Web frontend no longer depends on axios. All HTTP traffic goes through a single fetch-based client (FetchClient in @ownclouders/web-client), and the libre-graph client is generated from the typescript-fetch template instead of typescript-axios.

Sending requests is a drop-in change. HttpClient keeps its per-request config including data, keeps cancel(), still resolves a non-JSON body as text, and still exposes response headers as headers['etag'] as well as headers.get('etag'). Errors still carry the body and status as error.data / error.statusCode and as error.response.data / error.response.status. mockAxiosResolve / mockAxiosReject still work, deprecated in favour of mockHttpResponse / mockHttpError.

Code that touches axios directly does have to be adapted — new HttpClient() options, the axios-only per-request config fields, the FetchClient argument to graph() / ocs() / UrlSign / WebDavOptions, camelCase names for OData-annotated graph fields, and the generated client's *ApiFactory / *ApiFp / *AxiosParamCreator exports. All of it is spelled out in changelog/unreleased/change-replace-axios-with-fetch.md.

Related Issue

  • Fixes OCISDEV-215

Motivation and Context

axios duplicates what the platform now provides. Dropping it removes a dependency from the frontend bundle and from the dependabot surface, and puts one client behind every request instead of an axios instance plus interceptor pairs.

How Has This Been Tested?

  • test environment: local, web/ pnpm workspace on macOS, plus a linked checkout of owncloud/web-extensions
  • pnpm check:types: passes
  • pnpm test:unit: 367 files / 2962 tests passing
  • pnpm check:format and pnpm lint: clean
  • new unit tests cover the compatibility surface: bracket vs. get() header access, cancel() on an in-flight request, per-request AbortSignal, error.response.*, and a non-JSON body resolving as text
  • backwards-compatibility check against owncloud/web-extensions: this branch and a master baseline were both linked into the extensions repo with the same five packages. check:types produced the same 253 pre-existing errors on both, byte-identical, none of them mentioning the changed APIs, and test:unit produced 660 passing tests on both with identical per-package counts. No regressions.

Types of changes

  • Technical debt
  • Breaking change (fix or feature that would cause existing functionality to change)

The breaking part is limited to consumers of the published packages that used axios-specific APIs directly; nothing in this repo or in owncloud/web-extensions does.

Checklist:

  • Code changes
  • Unit tests added
  • Acceptance tests added
  • Documentation ticket raised:

@mzner
mzner requested a review from a team as a code owner September 9, 2026 06:39
@mzner
mzner force-pushed the chore/OCISDEV-215/replace-axios-with-native-fetch branch from ee9f1f5 to d690213 Compare September 9, 2026 06:39
@kw-security

kw-security commented Sep 9, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

Comment thread web/packages/web-client/scripts/generate-openapi.sh
@@ -39,14 +39,18 @@ export const GetFileContentsFactory = (dav: DAV, { axiosClient }: WebDavOptions)
response,
body: response.data,
headers: {
// bracket access, not get(): an absent header stays undefined here rather than null
ETag: response.headers['etag'],
'OC-ETag': response.headers['oc-etag'],
'OC-FileId': response.headers['oc-fileid']
}
}
} catch (error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Also passes through error?.name === 'AbortError'? AppWrapper.vue's loadFileTask checks that name to swallow cancelled loads — right now it gets wrapped into a generic HttpError here.

@mzner mzner Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right, fixed in 0c69b74: an abort is rethrown unwrapped now (error.name === 'AbortError' || opts.signal?.aborted), so loadFileTask still sees the name.

Comment thread web/packages/web-pkg/src/http/client.ts Outdated

const controller = new AbortController()
const signals = [clientSignal, requestSignal]
const abort = (source: AbortSignal) => controller.abort(source.reason)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

source.reason isn't guaranteed to be an AbortError-named DOMException. fetchClient.ts:37 only treats the rejection as cancellation on error.name === 'AbortError' — a non-AbortError reason here degrades to a 500 HttpError and can trip maintenance detection. Same normalization as cancel() a few lines down?

@mzner mzner Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0c69b74, from the other side: the core treats any rejection on an aborted signal as cancellation instead of trusting the reason's name. combineSignals is AbortSignal.any now and forwards the reason verbatim.

return JSON.stringify(body)
}

private async buildError(response: Response): Promise<HttpError> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

responseType isn't threaded through from fetch()/request() — error bodies always parse as json/text even for blob callers (previewService.ts:178, Avatar.vue, getFileUrl.ts).

@mzner mzner Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0c69b74buildError takes the caller's responseType, so a blob caller keeps getting a Blob on error.data.


private async buildError(response: Response): Promise<HttpError> {
// Clone so that HttpError.response still exposes an unread body to callers.
const data = await this.readBodySafely(response.clone())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Couldn't find a caller that reads HttpError.response's body — everything uses .data/.statusCode. If that's right, the clone + the defineProperty shim below can go.

@mzner mzner Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The clone is gone (0c69b74). Kept the defineProperty shims: error.response.data and error.response.headers['x'] are the axios spellings outside this repo, and it's a published package — web-extensions reads error.response.status today, so I'd rather not narrow the surface in the same PR that changes the transport. Happy to drop them if you'd prefer the smaller surface.

return Promise.reject(error)
const httpClient = new FetchClient({
...(headers && { headers }),
onResponse: ({ response, status, requestUrl }) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same maintenance-detection logic duplicated in web-pkg/src/services/client/client.ts:189-199. Worth one shared helper?

@mzner mzner Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 0c69b74: maintenanceResponseHandler in web-client/src/helpers/maintenance.ts, used by both. Their disagreement about clearing on an unrelated error is an explicit clearOnUnrelatedError flag rather than silently unified — OCISDEV-1413 to decide which one is right.

Comment thread web/packages/web-pkg/src/http/client.ts Outdated
* Ties a per-request signal to the client-wide one without leaking a listener per request:
* the caller disposes once the request has settled.
*/
const combineSignals = (clientSignal: AbortSignal, requestSignal?: AbortSignal) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AbortSignal.any() covers this natively given the Node engine floor — would drop the manual listener bookkeeping.

@mzner mzner Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 0c69b74.

users: UsersFactory({ axiosClient, config }),
groups: GroupsFactory({ axiosClient, config }),
permissions: PermissionsFactory({ axiosClient, config })
activities: ActivitiesFactory({ httpClient, config }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

httpClient passed to all 8 factories — none appear to use it (transport goes through config.fetchApi). Leftover from axios?

@mzner mzner Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Leftover from axios, dropped in 0c69b74. Transport goes through config.fetchApi only.

const params = (init as Record<symbol, Record<string, string>>)?.[undeclaredParams]
return httpClient.fetch(String(input), {
method: init?.method,
headers: Object.fromEntries(new Headers(init?.headers).entries()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Round-trips through Headers and back to a plain object on every Graph call, then buildHeaders() wraps it in a Headers again. Pass init?.headers straight through?

@mzner mzner Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0c69b74init?.headers goes straight through as a HeadersInit, no round-trip.

…ed client

Regenerate the libre-graph client with the typescript-fetch template and
rewrite the eight facade factories against it:

- the generated APIs are classes (`new XxxApi(config)`), take a single
  request-parameters object and resolve to the payload rather than an
  envelope, so the wrappers no longer destructure `{ data }`
- every generated request is routed through `FetchClient` via
  `Configuration.fetchApi`, keeping header injection, maintenance
  detection and `HttpError`-on-non-2xx intact. Callers keep seeing
  `HttpError` with `statusCode` and `data`, never `ResponseError`
- `toInitOverrides` merges our per-request headers into the generated
  `RequestInit` instead of replacing them, which a plain object would do
  because the runtime spreads `initOverrides` shallowly
- undeclared query parameters travel on a symbol-keyed channel, since the
  runtime assembles the URL before it applies `initOverrides`

`--type-mappings=DateTime=string` keeps date-times as strings, matching
what the previous template emitted and what the app expects.
…fields

The typescript-fetch template emits real JSON serializers, so annotated
fields like `@libre.graph.permissions.actions`, `@odata.id`, `@UI.Hidden`
and `@client.synchronize` are mapped to camelCase properties on the way in
and back to their wire names on the way out. The wire format is unchanged;
only the TypeScript-side names are. Reads left on the old names would have
silently resolved to `undefined`, and writes would have been dropped.

The OData `$filter` expression in FileSideBar keeps its annotated spelling,
being a query value rather than a property.

Response-only fields are now `readonly`, which the editable copies and test
fixtures built from them need to opt out of, hence `writeable()`.
Nothing imports axios any more, so remove it from the eight manifests that
declared it and update the web-client README, whose usage examples still
instantiated an axios client.

Also applies prettier to the files touched over the course of the migration.
Dropping axios changed more than the transport. Four spellings that consumers
outside this repo were written against disappeared with it, and none of them had
to.

Response headers were a plain lowercased object under axios, so `headers['etag']`
was the only way to read one; a fetch `Response` exposes a `Headers`, where
`get('etag')` is. `httpHeaders` wraps the native object in a proxy that answers
both, case-insensitively, and enumerates the lowercased names — it stays a real
`Headers`, so neither spelling is a migration.

A rejected request carried its body on `error.response.data` and its status on
`error.response.status`. `HttpError` names them `data` and `statusCode`, which
this repo unifies on, but the response those shadow is reachable from outside it,
so `buildError` defines both on the response as well and keeps axios's
`Request failed with status code <n>` message. The body is read from a clone, so
callers still get an unread one.

The config `HttpClient` takes has always named the request body `data`, and web
extensions pass `data` when they issue raw WebDAV requests through
`httpAuthenticated`. Carrying the core's `body` up would have dropped those
bodies silently, so `RequestConfig` keeps `data` and `send` translates it. For
the same reason a default-responseType body that is not JSON falls back to the
raw text instead of throwing: a WebDAV multistatus answered without an explicit
`responseType: 'text'` used to arrive as a string, and a `SyntaxError` there
would turn a working request into a failing one.

`cancel()` is back, on an `AbortController` instead of a `CancelToken`. Combining
the client-wide signal with a per-request one needs a listener per request, so
`send` disposes of them once the request has settled rather than letting them
accumulate on a long-lived client.

`mockAxiosResolve` and `mockAxiosReject` stay exported from web-test-helpers as
deprecated aliases, so suites written against the axios era keep compiling.
The typescript-fetch templates omit fields the spec marks `readOnly: true` from
the generated `*ToJSON` serializers. Every field of `Quota` is annotated that
way, so `updateDrive(id, { quota: { total: 500 } })` went out as
`{ quota: {} }` and quotas could no longer be changed. Seventeen other models
were affected the same way, among them `UserUpdate` and `ItemReference`.

The annotation is now stripped from the spec before generating, which is what
the axios-generated client effectively did. That also drops the `readonly`
modifiers, so the `writeable` escape hatch is no longer needed.
The generated runtime wraps whatever its `fetchApi` throws in a `FetchError`,
which buried the `HttpError` the core raises for a non-2xx response. Callers
that branch on the status saw neither `statusCode` nor `data`, so the banned
password hint on a public link never appeared and an aborted graph request
stopped looking like an abort.

Rethrowing from an `onError` middleware escapes the wrap for every graph
operation at once.
…dies

A caller-supplied `Content-Type: multipart/form-data` carries no boundary. Axios
replaced the header before sending, fetch passes it through, so the server could not
parse the body and the admin settings logo upload silently did nothing.
Header names are case-insensitive, but the header layers were merged with an
object spread, where object keys are not. `Authorization` and `authorization`
both survived it, and a spec-compliant `Headers` applies a record init with
`append`, so the two were joined into `Bearer stale, Bearer fresh` instead of
the override replacing the token. The graph bridge lowercased every per-request
header name while the client-wide ones are canonically cased, which made the
collision the normal case rather than an edge one.

Merge each layer with `Headers.set()` instead, in the fetch client and in the
graph `initOverrides` bridge, which had its own copy of the same spread. The
new specs run under `@vitest-environment node`: happy-dom's `Headers` applies a
record init with `set` semantics and would hide the bug.

Also from the PR review:

- read an error body with the caller's `responseType` rather than cloning the
  response, so a `blob` caller keeps getting a Blob on `error.data`
- treat any rejection on an aborted signal as an abort, since `abort(reason)`
  rejects with the reason verbatim and only defaults to an `AbortError`
- share the maintenance-mode `onResponse` handler between the webdav client and
  `ClientService`, with their pre-existing disagreement about clearing on an
  unrelated error made explicit instead of silently unified
- pass the graph headers straight through as a `HeadersInit` and drop the
  unused `httpClient` from the graph factory options
- collapse `combineSignals` onto `AbortSignal.any`
- let an abort through `getFileContents` unwrapped
- assert maintenance detection against the real `shouldResponseTriggerMaintenance`
  instead of a mock, which had been hiding that a 500 is not a maintenance signal
@mzner
mzner requested a review from gauravsoni119 September 10, 2026 09:24
@mzner
mzner force-pushed the chore/OCISDEV-215/replace-axios-with-native-fetch branch from 4e2946e to 520c584 Compare September 10, 2026 09:28
@LukasHirt

Copy link
Copy Markdown
Contributor

Add full-ci to the title please. We better run every test with change this large.

@mzner mzner changed the title chore: [OCISDEV-215] replace axios with the native fetch API in Web chore[full-ci]: [OCISDEV-215] replace axios with the native fetch API in Web Sep 10, 2026
@mzner mzner changed the title chore[full-ci]: [OCISDEV-215] replace axios with the native fetch API in Web [full-ci] chore: [OCISDEV-215] replace axios with the native fetch API in Web Sep 10, 2026
@mzner
mzner force-pushed the chore/OCISDEV-215/replace-axios-with-native-fetch branch from 520c584 to f536cb1 Compare September 10, 2026 12:04
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.

3 participants