Skip to content

Commit

Permalink
feat(network): add fetch implementation (#2226)
Browse files Browse the repository at this point in the history
* feat(network): add fetch implementation

* chore: use enum instead of hard code
  • Loading branch information
wzhudev committed May 12, 2024
1 parent 5f26e90 commit b970fe1
Show file tree
Hide file tree
Showing 8 changed files with 231 additions and 23 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@
"mengshukeji",
"Neue",
"numfmt",
"OOXML",
"opencollective",
"opentype",
"OOXML",
"Overlines",
"Pacifico",
"Plass",
Expand Down Expand Up @@ -107,6 +107,7 @@
"Xinwei",
"Xlookup",
"XMATCH",
"XSSI",
"yuhongz"
],
"vsicons.presets.angular": false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23548,7 +23548,7 @@ export const DEFAULT_WORKBOOK_DATA_DEMO: IWorkbookData = {
}),
},
{
name: 'SHEET_AUTO_FILTER',
name: 'SHEET_FILTER_PLUGIN',
data: JSON.stringify({
'sheet-0011': {
ref: {
Expand Down
1 change: 1 addition & 0 deletions packages/network/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
export { HTTPHeaders } from './services/http/headers';
export { HTTPService } from './services/http/http.service';
export { IHTTPImplementation } from './services/http/implementations/implementation';
export { FetchHTTPImplementation } from './services/http/implementations/fetch';
export { XHRHTTPImplementation } from './services/http/implementations/xhr';
export { HTTPRequest, type HTTPRequestMethod } from './services/http/request';
export { HTTPResponse, type HTTPEvent, HTTPResponseError } from './services/http/response';
Expand Down
52 changes: 38 additions & 14 deletions packages/network/src/services/http/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,13 @@ export const ApplicationJSONType = 'application/json';
export class HTTPHeaders {
private readonly _headers: Map<string, string[]> = new Map();

constructor(headers?: IHeadersConstructorProps | string) {
constructor(headers?: IHeadersConstructorProps | Headers | string) {
if (typeof headers === 'string') {
// split header text and serialize them to HTTPHeaders
headers.split('\n').forEach((header) => {
const [name, value] = header.split(':');
if (name && value) {
this._setHeader(name, value);
}
});
} else {
if (headers) {
Object.keys(headers).forEach(([name, value]) => {
this._setHeader(name, value);
});
}
this._handleHeadersString(headers);
} else if (headers instanceof Headers) {
this._handleHeaders(headers);
} else if (headers) {
this._handleHeadersConstructorProps(headers);
}
}

Expand All @@ -58,6 +50,17 @@ export class HTTPHeaders {
return this._headers.has(k) ? this._headers.get(k)! : null;
}

toHeadersInit(): HeadersInit {
const headers: HeadersInit = {};
this._headers.forEach((values, key) => {
headers[key] = values.join(',');
});

headers.Accept ??= 'application/json, text/plain, */*';

return headers;
}

private _setHeader(name: string, value: string | number | boolean): void {
const lowerCase = name.toLowerCase();
if (this._headers.has(lowerCase)) {
Expand All @@ -66,4 +69,25 @@ export class HTTPHeaders {
this._headers.set(lowerCase, [value.toString()]);
}
}

private _handleHeadersString(headers: string): void {
headers.split('\n').forEach((header) => {
const [name, value] = header.split(':');
if (name && value) {
this._setHeader(name, value);
}
});
}

private _handleHeadersConstructorProps(headers: IHeadersConstructorProps): void {
Object.keys(headers).forEach(([name, value]) => {
this._setHeader(name, value);
});
}

private _handleHeaders(headers: Headers): void {
headers.forEach((value, name) => {
this._setHeader(name, value);
});
}
}
174 changes: 174 additions & 0 deletions packages/network/src/services/http/implementations/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* Copyright 2023-present DreamNum Inc.
*
* 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:https://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.
*/

/* eslint-disable ts/no-explicit-any */
/* eslint-disable no-case-declarations */

import type { Subscriber } from 'rxjs';
import { Observable } from 'rxjs';
import type { HTTPRequest } from '../request';
import type { HTTPEvent, HTTPResponseBody } from '../response';
import { HTTPResponse, HTTPResponseError } from '../response';
import { HTTPHeaders } from '../headers';
import { HTTPStatusCode } from '../http';
import type { IHTTPImplementation } from './implementation';

// CREDIT: This implementation is inspired by (and uses lots of code from) Angular's HttpClient implementation.

/**
* An HTTP implementation using Fetch API. This implementation can both run in browser and Node.js.
*
* It does not support streaming response yet (May 12, 2024).
*/
export class FetchHTTPImplementation implements IHTTPImplementation {
send(request: HTTPRequest): Observable<HTTPEvent<any>> {
return new Observable((subscriber) => {
const abortController = new AbortController();
this._send(request, subscriber, abortController).then(() => {}, (error) => {
subscriber.error(new HTTPResponseError({
error,

}));
});

return () => abortController.abort();
});
}

private async _send(request: HTTPRequest, subscriber: Subscriber<HTTPEvent<any>>, abortController: AbortController) {
let response: Response;

try {
const fetchParams = this._parseFetchParamsFromRequest(request);
const fetchPromise = fetch(request.getUrlWithParams(), {
signal: abortController.signal,
...fetchParams,
});

response = await fetchPromise;
} catch (error: any) {
subscriber.error(new HTTPResponseError({
error,
status: error.status ?? 0,
statusText: error.statusText ?? 'Unknown Error',
headers: error.headers,
}));

return;
}

const responseHeaders = new HTTPHeaders(response.headers);
const status = response.status;
const statusText = response.statusText;

let body: HTTPResponseBody = null;
if (response.body) {
body = await this._readBody(request, response, subscriber);
}

const ok = status >= HTTPStatusCode.Ok && status < HTTPStatusCode.MultipleChoices;
if (ok) {
subscriber.next(new HTTPResponse({
body,
headers: responseHeaders,
status,
statusText,
}));
} else {
subscriber.error(new HTTPResponseError({
error: body,
status,
statusText,
headers: responseHeaders,
}));
}

subscriber.complete();
}

private async _readBody(
request: HTTPRequest,
response: Response,
subscriber: Subscriber<HTTPEvent<any>>
): Promise<HTTPResponseBody> {
const chunks: Uint8Array[] = [];
const reader = response.body!.getReader();

let receivedLength = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;

chunks.push(value);
receivedLength += value.length;
}

const all = mergeChunks(chunks, receivedLength);
try {
const contentType = response.headers.get('content-type') ?? '';
const body = deserialize(request, all, contentType);
return body;
} catch (error) {
subscriber.error(new HTTPResponseError({
error,
status: response.status,
statusText: response.statusText,
headers: new HTTPHeaders(response.headers),
}));
return null;
}
}

private _parseFetchParamsFromRequest(request: HTTPRequest): RequestInit {
const fetchParams: RequestInit = {
method: request.method,
headers: request.getHeadersInit(),
body: request.getBody(),
credentials: request.withCredentials ? 'include' : undefined,
};

return fetchParams;
}
}

function mergeChunks(chunks: Uint8Array[], totalLength: number): Uint8Array {
const all = new Uint8Array(totalLength);
let position = 0;

for (const chunk of chunks) {
all.set(chunk, position);
position += chunk.length;
}

return all;
}

const XSSI_PREFIX = /^\)\]\}',?\n/;
function deserialize(request: HTTPRequest, bin: Uint8Array, contentType: string): HTTPResponseBody {
switch (request.responseType) {
case 'json':
const text = new TextDecoder().decode(bin).replace(XSSI_PREFIX, '');
return text === '' ? null : JSON.parse(text);
case 'text':
return new TextDecoder().decode(bin);
case 'blob':
return new Blob([bin], { type: contentType });
case 'arraybuffer':
return bin.buffer;
default:
throw new Error(`[FetchHTTPImplementation]: unknown response type: ${request.responseType}.`);
}
}
3 changes: 2 additions & 1 deletion packages/network/src/services/http/implementations/xhr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* limitations under the License.
*/

/* eslint-disable max-lines-per-function */
/* eslint-disable ts/no-explicit-any */

import type { Nullable } from '@univerjs/core';
Expand All @@ -28,7 +29,7 @@ import { HTTPResponse, HTTPResponseError, ResponseHeader } from '../response';
import type { IHTTPImplementation } from './implementation';

/**
* A HTTP implementation using XHR. HTTP service provided by this service could only be async (we do not support sync XHR now).
* An HTTP implementation using XHR. HTTP service provided by this service could only be async (we do not support sync XHR now).
*/
export class XHRHTTPImplementation implements IHTTPImplementation {
send(request: HTTPRequest): Observable<HTTPEvent<any>> {
Expand Down
5 changes: 5 additions & 0 deletions packages/network/src/services/http/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,9 @@ export class HTTPRequest {

return body ? `${body}` : null;
}

getHeadersInit(): HeadersInit {
const headersInit = this.headers.toHeadersInit();
return headersInit;
}
}
14 changes: 8 additions & 6 deletions packages/network/src/services/http/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import type { HTTPHeaders } from './headers';
*/
export type HTTPEvent<T> = HTTPResponse<T> | HTTPResponseError;

export type HTTPResponseBody = string | ArrayBuffer | Blob | object | null;

/** Wraps (success) response info. */
export class HTTPResponse<T> {
readonly body: T;
Expand All @@ -47,9 +49,9 @@ export class HTTPResponse<T> {
}

export class HTTPResponseError {
readonly headers: HTTPHeaders;
readonly status: number;
readonly statusText: string;
readonly headers?: HTTPHeaders;
readonly status?: number;
readonly statusText?: string;
readonly error: any;

constructor({
Expand All @@ -58,9 +60,9 @@ export class HTTPResponseError {
statusText,
error,
}: {
headers: HTTPHeaders;
status: number;
statusText: string;
headers?: HTTPHeaders;
status?: number;
statusText?: string;
error: any;
}) {
this.headers = headers;
Expand Down

0 comments on commit b970fe1

Please sign in to comment.