Repository: dream-num/univer
Pull request #4950: fix(support): network supports form data
Author: login: Dushusir
Created: 2025-04-08T07:08:55Z | Merged: 2025-04-09T09:26:18Z

<!--
 Thank you for submitting a Pull Request.
 Please read our Pull Request guidelines:
 https://github.com/dream-num/univer/blob/dev/CONTRIBUTING.md#submitting-pull-requests
-->

<!-- Associate issues with the pull request if there is one. Separate them width commas. -->
<!-- Feel free to delete this if there is no related issue. -->
- [x] HTTPService supports form data and adds testing for HTTPRequests

<!-- A description of the proposed changes. -->

<!-- How to test them. -->

<!-- Uncomment the below lines if there are breaking changes introduced in this PR. -->
<!-- BREAKING CHANGE:
Before:

After: -->

## Pull Request Checklist

- [ ] Related tickets or issues have been linked in the PR description (or missing issue).
- [ ] [Naming convention](https://github.com/dream-num/univer/blob/dev/docs/NAMING_CONVENTION.md) is followed (**do please** check it especially when you created new plugins, commands and resources).
- [ ] Unit tests have been added for the changes (if applicable).
- [ ] Breaking changes have been documented (or no breaking changes introduced in this PR).



Diff:
diff --git a/packages/network/src/services/http/__testing__/request.spec.ts b/packages/network/src/services/http/__testing__/request.spec.ts
new file mode 100644
index 000000000000..e820627c1a0c
--- /dev/null
+++ b/packages/network/src/services/http/__testing__/request.spec.ts
@@ -0,0 +1,163 @@
+/**
+ * Copyright 2023-present DreamNum Co., Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { beforeEach, describe, expect, it } from 'vitest';
+import { ApplicationJSONType, HTTPHeaders } from '../headers';
+import { HTTPParams } from '../params';
+import { __TEST_ONLY_RESET_REQUEST_UID_DO_NOT_USE_IN_PRODUCTION, HTTPRequest } from '../request';
+
+describe('test class HTTPRequest', () => {
+    beforeEach(() => {
+        __TEST_ONLY_RESET_REQUEST_UID_DO_NOT_USE_IN_PRODUCTION();
+    });
+
+    it('should have incremental UIDs for multiple requests', () => {
+        const request1 = new HTTPRequest('GET', 'https://example.com');
+        const request2 = new HTTPRequest('GET', 'https://example.com');
+
+        expect(request1.uid).toBe(0);
+        expect(request2.uid).toBe(1);
+    });
+
+    describe('getUrlWithParams', () => {
+        it('should return original URL when no params', () => {
+            const request = new HTTPRequest('GET', 'https://example.com');
+            expect(request.getUrlWithParams()).toBe('https://example.com');
+        });
+
+        it('should append params with ? when URL has no query params', () => {
+            const params = new HTTPParams({ key: 'value' });
+            const request = new HTTPRequest('GET', 'https://example.com', {
+                headers: new HTTPHeaders(),
+                params,
+                responseType: 'json',
+                withCredentials: false,
+            });
+
+            expect(request.getUrlWithParams()).toBe('https://example.com?key=value');
+        });
+
+        it('should append params with & when URL already has query params', () => {
+            const params = new HTTPParams({ key2: 'value2' });
+            const request = new HTTPRequest('GET', 'https://example.com?key1=value1', {
+                headers: new HTTPHeaders(),
+                params,
+                responseType: 'json',
+                withCredentials: false,
+            });
+
+            expect(request.getUrlWithParams()).toBe('https://example.com?key1=value1&key2=value2');
+        });
+    });
+
+    describe('getBody', () => {
+        it('should return null for empty body', () => {
+            const request = new HTTPRequest('GET', 'https://example.com', {
+                headers: new HTTPHeaders(),
+                responseType: 'json',
+                withCredentials: false,
+            });
+
+            expect(request.getBody()).toBeNull();
+        });
+
+        it('should return FormData as is', () => {
+            const formData = new FormData();
+            const request = new HTTPRequest('POST', 'https://example.com', {
+                body: formData,
+                headers: new HTTPHeaders(),
+                responseType: 'json',
+                withCredentials: false,
+            });
+
+            expect(request.getBody()).toBe(formData);
+        });
+
+        it('should stringify object for JSON content type', () => {
+            const body = { name: 'test', value: 123 };
+            const headers = new HTTPHeaders({
+                'Content-Type': ApplicationJSONType,
+            });
+
+            const request = new HTTPRequest('POST', 'https://example.com', {
+                body,
+                headers,
+                responseType: 'json',
+                withCredentials: false,
+            });
+
+            expect(request.getBody()).toBe(JSON.stringify(body));
+        });
+
+        it('should convert non-object body to string', () => {
+            const request = new HTTPRequest('POST', 'https://example.com', {
+                body: 123,
+                headers: new HTTPHeaders(),
+                responseType: 'text',
+                withCredentials: false,
+            });
+
+            expect(request.getBody()).toBe('123');
+        });
+    });
+
+    describe('getHeadersInit', () => {
+        it('should return headers from HTTPHeaders object', () => {
+            const headers = new HTTPHeaders({
+                'X-Custom-Header': 'test',
+                Authorization: 'Bearer token',
+            });
+
+            const request = new HTTPRequest('GET', 'https://example.com', {
+                headers,
+                responseType: 'json',
+                withCredentials: false,
+            });
+
+            const headersInit = request.getHeadersInit();
+
+            expect(headersInit).toHaveProperty('X-Custom-Header'.toLowerCase(), 'test');
+            expect(headersInit).toHaveProperty('Authorization'.toLowerCase(), 'Bearer token');
+        });
+
+        it('should set Content-Type to application/json for JSON body', () => {
+            const headers = new HTTPHeaders();
+            const request = new HTTPRequest('POST', 'https://example.com', {
+                headers,
+                body: { name: 'test' },
+                responseType: 'json',
+                withCredentials: false,
+            });
+
+            const headersInit = request.getHeadersInit();
+            expect(headersInit).toHaveProperty('content-type', 'application/json;charset=UTF-8');
+        });
+
+        it('should not set Content-Type for FormData body', () => {
+            const formData = new FormData();
+            const headers = new HTTPHeaders();
+            const request = new HTTPRequest('POST', 'https://example.com', {
+                headers,
+                body: formData,
+                responseType: 'json',
+                withCredentials: false,
+            });
+
+            const headersInit = request.getHeadersInit();
+            expect(headersInit).not.toHaveProperty('content-type');
+        });
+    });
+});
diff --git a/packages/network/src/services/http/headers.ts b/packages/network/src/services/http/headers.ts
index 2c5f7b0ece11..4c6c3c6ba44d 100644
--- a/packages/network/src/services/http/headers.ts
+++ b/packages/network/src/services/http/headers.ts
@@ -21,6 +21,18 @@ interface IHeadersConstructorProps {
 // Header keys in `HTTPHeaders` should be in lower case.
 export const ApplicationJSONType = 'application/json';
 
+/**
+ * Check if the content type is application/json
+ * "application/json" or "application/json; charset=utf-8" or ["application/json"]
+ * @param contentType
+ */
+export function isApplicationJSONType(contentType: string | string[]): boolean {
+    if (Array.isArray(contentType)) {
+        return contentType.some((type) => type.includes(ApplicationJSONType));
+    }
+    return contentType.includes(ApplicationJSONType);
+}
+
 /**
  * It wraps headers of HTTP requests' and responses' headers.
  */
@@ -54,14 +66,18 @@ export class HTTPHeaders {
         this._setHeader(key, value);
     }
 
-    toHeadersInit(): HeadersInit {
+    toHeadersInit(body?: any): HeadersInit {
         const headers: HeadersInit = {};
         this._headers.forEach((values, key) => {
             headers[key] = values.join(',');
         });
 
         headers.accept ??= 'application/json, text/plain, */*';
-        headers['content-type'] ??= 'application/json;charset=UTF-8';
+
+        // If the request body is FormData, the browser will automatically generate Content Type with boundary
+        if (!(body instanceof FormData)) {
+            headers['content-type'] ??= 'application/json;charset=UTF-8';
+        }
 
         return headers;
     }
diff --git a/packages/network/src/services/http/request.ts b/packages/network/src/services/http/request.ts
index 5ae3064da7b1..d5b28219972e 100644
--- a/packages/network/src/services/http/request.ts
+++ b/packages/network/src/services/http/request.ts
@@ -17,7 +17,7 @@
 import type { HTTPHeaders } from './headers';
 import type { HTTPResponseType } from './http';
 import type { HTTPParams } from './params';
-import { ApplicationJSONType } from './headers';
+import { ApplicationJSONType, isApplicationJSONType } from './headers';
 
 export type HTTPRequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
 
@@ -62,10 +62,15 @@ export class HTTPRequest {
         return `${this.url}${this.url.includes('?') ? '&' : '?'}${params}`;
     }
 
-    getBody(): string | null {
+    getBody(): string | FormData | null {
         const contentType = this.headers.get('Content-Type') ?? ApplicationJSONType;
         const body = this.requestParams?.body;
-        if (contentType === ApplicationJSONType && body && typeof body === 'object') {
+
+        if (body instanceof FormData) {
+            return body;
+        }
+
+        if (isApplicationJSONType(contentType) && body && typeof body === 'object') {
             return JSON.stringify(body);
         }
 
@@ -73,7 +78,7 @@ export class HTTPRequest {
     }
 
     getHeadersInit(): HeadersInit {
-        const headersInit = this.headers.toHeadersInit();
+        const headersInit = this.headers.toHeadersInit(this.requestParams?.body);
         return headersInit;
     }
 }


Reviews:
user: login: wzhudev
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T08:45:36Z
user: login: Dushusir
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T08:55:13Z

Review comments:
diff_hunk: @@ -54,14 +66,18 @@ export class HTTPHeaders {
         this._setHeader(key, value);
     }
 
-    toHeadersInit(): HeadersInit {
+    toHeadersInit(body?: any): HeadersInit {
         const headers: HeadersInit = {};
         this._headers.forEach((values, key) => {
             headers[key] = values.join(',');
         });
 
         headers.accept ??= 'application/json, text/plain, */*';
-        headers['content-type'] ??= 'application/json;charset=UTF-8';
+
+        // If the request body is FormData, the browser will automatically generate Content Type with boundary
+        if (!(body instanceof FormData)) {
path: packages/network/src/services/http/headers.ts
user: login: wzhudev
body: Consider if the body is `instanceof FormData`, is it better to set the `content-type` to `application/x-www-form-urlencode` for the user.
created_at: 2025-04-09T08:45:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -54,14 +66,18 @@ export class HTTPHeaders {
         this._setHeader(key, value);
     }
 
-    toHeadersInit(): HeadersInit {
+    toHeadersInit(body?: any): HeadersInit {
         const headers: HeadersInit = {};
         this._headers.forEach((values, key) => {
             headers[key] = values.join(',');
         });
 
         headers.accept ??= 'application/json, text/plain, */*';
-        headers['content-type'] ??= 'application/json;charset=UTF-8';
+
+        // If the request body is FormData, the browser will automatically generate Content Type with boundary
+        if (!(body instanceof FormData)) {
path: packages/network/src/services/http/headers.ts
user: login: Dushusir
body: Other PR optimizations https://github.com/dream-num/univer-pro/issues/4222
created_at: 2025-04-09T08:55:13Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-08T07:09:42Z
body: ## View Deployment
| 📑 Demo (React@19) | Demo (React@16) | 📚 Storybook |
| --- | --- | --- |
| [🔗 Preview link](https://univer-gg5dfgtfw-univer.vercel.app/) | [🔗 Preview link](https://univer-gg5dfgtfw-univer.vercel.app/react16/) | [🔗 Preview link](https://univer-design-9pc3nmhzs-univer.vercel.app/) |
author_association: NONE
user: login: univer-bot[bot]
created_at: 2025-04-08T07:10:07Z
body: 
<!-- do-not-remove-translate-flag-issue 4950 -->

> Bot detected the issue body's language is not English, translate it automatically. 👯👭🏻🧑‍🤝‍🧑👫🧑🏿‍🤝‍🧑🏻👩🏾‍🤝‍👨🏿👬🏿

Origin Title: fix(support): network supports form data

**Title:** fix(support): network supports form data

---

<!--
 Thank you for submitting a Pull Request.
 Please read our Pull Request guidelines:
 https://github.com/dream-num/univer/blob/dev/CONTRIBUTING.md#submitting-pull-requests
-->

<!-- Associate issues with the pull request if there is one. Separate them width commas. -->
<!-- Feel free to delete this if there is no related issue. -->
- [x] HTTPService 支持 form data，并为 HTTPRequest 添加测试

<!-- A description of the proposed changes. -->

<!-- How to test them. -->

<!-- Uncomment the below lines if there are breaking changes introduced in this PR. -->
<!-- BREAKING CHANGE:
Before:

After: -->

## Pull Request Checklist

- [ ] Related tickets or issues have been linked in the PR description (or missing issue).
- [ ] [Naming convention](https://github.com/dream-num/univer/blob/dev/docs/NAMING_CONVENTION.md) is followed (**do please** check it especially when you created new plugins, commands and resources).
- [ ] Unit tests have been added for the changes (if applicable).
- [ ] Breaking changes have been documented (or no breaking changes introduced in this PR).

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-08T07:16:34Z
body: <!-- playwright-report-github-action -- 🎭 Playwright Tests -->

### Playwright test results

![passed](https://icongr.am/octicons/check-circle.svg?size=14&color=3fb950)  **25 passed**

#### Details

![stats](https://icongr.am/octicons/pulse.svg?size=14&color=abb4bf)  25 tests across 12 suites  
![duration](https://icongr.am/octicons/clock.svg?size=14&color=abb4bf)  6 minutes, 10 seconds  
![commit](https://icongr.am/octicons/git-pull-request.svg?size=14&color=abb4bf)  [75ed33f](https://github.com/dream-num/univer/commit/75ed33fe35f6747ecbd099088e1530019d36bd47)  
![info](https://icongr.am/octicons/info.svg?size=14&color=abb4bf)  For more information, [see full report]()
author_association: NONE
user: login: codecov[bot]
created_at: 2025-04-08T07:20:07Z
body: ## [Codecov](https://app.codecov.io/gh/dream-num/univer/pull/4950?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dream-num) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 33.92%. Comparing base [(`4c0e665`)](https://app.codecov.io/gh/dream-num/univer/commit/4c0e6654540a7a1714787bcf0e4497c96ad51cdf?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dream-num) to head [(`75ed33f`)](https://app.codecov.io/gh/dream-num/univer/commit/75ed33fe35f6747ecbd099088e1530019d36bd47?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dream-num).
> Report is 1 commits behind head on dev.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##              dev    #4950      +/-   ##
==========================================
+ Coverage   33.89%   33.92%   +0.02%     
==========================================
  Files        2641     2641              
  Lines      138329   138336       +7     
  Branches    30756    30759       +3     
==========================================
+ Hits        46892    46931      +39     
+ Misses      91437    91405      -32     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/dream-num/univer/pull/4950?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dream-num).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dream-num).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE