[full-ci] chore: [OCISDEV-215] replace axios with the native fetch API in Web - #12910
[full-ci] chore: [OCISDEV-215] replace axios with the native fetch API in Web#12910mzner wants to merge 18 commits into
Conversation
ee9f1f5 to
d690213
Compare
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
| @@ -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) { | |||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Right, fixed in 0c69b74: an abort is rethrown unwrapped now (error.name === 'AbortError' || opts.signal?.aborted), so loadFileTask still sees the name.
|
|
||
| const controller = new AbortController() | ||
| const signals = [clientSignal, requestSignal] | ||
| const abort = (source: AbortSignal) => controller.abort(source.reason) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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> { |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
Fixed in 0c69b74 — buildError 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()) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 }) => { |
There was a problem hiding this comment.
Same maintenance-detection logic duplicated in web-pkg/src/services/client/client.ts:189-199. Worth one shared helper?
There was a problem hiding this comment.
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.
| * 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) => { |
There was a problem hiding this comment.
AbortSignal.any() covers this natively given the Node engine floor — would drop the manual listener bookkeeping.
| users: UsersFactory({ axiosClient, config }), | ||
| groups: GroupsFactory({ axiosClient, config }), | ||
| permissions: PermissionsFactory({ axiosClient, config }) | ||
| activities: ActivitiesFactory({ httpClient, config }), |
There was a problem hiding this comment.
httpClient passed to all 8 factories — none appear to use it (transport goes through config.fetchApi). Leftover from axios?
There was a problem hiding this comment.
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()), |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Fixed in 0c69b74 — init?.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
4e2946e to
520c584
Compare
|
Add full-ci to the title please. We better run every test with change this large. |
520c584 to
f536cb1
Compare
Description
The Web frontend no longer depends on axios. All HTTP traffic goes through a single fetch-based client (
FetchClientin@ownclouders/web-client), and the libre-graph client is generated from thetypescript-fetchtemplate instead oftypescript-axios.Sending requests is a drop-in change.
HttpClientkeeps its per-request config includingdata, keepscancel(), still resolves a non-JSON body as text, and still exposes response headers asheaders['etag']as well asheaders.get('etag'). Errors still carry the body and status aserror.data/error.statusCodeand aserror.response.data/error.response.status.mockAxiosResolve/mockAxiosRejectstill work, deprecated in favour ofmockHttpResponse/mockHttpError.Code that touches axios directly does have to be adapted —
new HttpClient()options, the axios-only per-request config fields, theFetchClientargument tograph()/ocs()/UrlSign/WebDavOptions, camelCase names for OData-annotated graph fields, and the generated client's*ApiFactory/*ApiFp/*AxiosParamCreatorexports. All of it is spelled out inchangelog/unreleased/change-replace-axios-with-fetch.md.Related Issue
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?
web/pnpm workspace on macOS, plus a linked checkout ofowncloud/web-extensionspnpm check:types: passespnpm test:unit: 367 files / 2962 tests passingpnpm check:formatandpnpm lint: cleanget()header access,cancel()on an in-flight request, per-requestAbortSignal,error.response.*, and a non-JSON body resolving as textowncloud/web-extensions: this branch and amasterbaseline were both linked into the extensions repo with the same five packages.check:typesproduced the same 253 pre-existing errors on both, byte-identical, none of them mentioning the changed APIs, andtest:unitproduced 660 passing tests on both with identical per-package counts. No regressions.Types of changes
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-extensionsdoes.Checklist: