Skip to content

Commit

Permalink
feat!: Use Proxy API to reduce bundle size
Browse files Browse the repository at this point in the history
feat!: Use Proxy API to reduce bundle size

chore

add encoding

fix jest errors

fix some tests

fix several broken tests

fix obsolete config test

allow setting header and signal in each request

add wait for util

prettify directory

fix broken tests

add meta options

fix paginator
  • Loading branch information
neet committed Jul 25, 2023
1 parent 68448ff commit 6e61c3d
Show file tree
Hide file tree
Showing 130 changed files with 2,343 additions and 3,312 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
},
"npm.packageManager": "yarn",
"eslint.packageManager": "yarn",
"jest.jestCommandLine": "jest --runInBand",
"typescript.tsdk": "node_modules/typescript/lib"
}
66 changes: 66 additions & 0 deletions src/builder/builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { snakeCase } from 'change-case';

import type { Http, HttpMetaParams } from '../http';
import { Paginator } from '../paginator';

function noop(): void {
//
}

const get =
<T>(http: Http, context: string[]) =>
(_: unknown, key: string) => {
// Promise not permitted
if (key === 'then') {
return;
}

return createBuilder<T>(http, [...context, key]);
};

const apply =
(http: Http, context: string[]) =>
(_1: unknown, _2: unknown, args: unknown[]): unknown => {
const action = context.pop();

if (action == undefined) {
throw new Error('No action specified');
}

if (action === 'select') {
return createBuilder(http, [...context, ...(args as string[])]);
}

const data = args[0];
const meta = args[1] as HttpMetaParams;
const path = '/' + context.map((name) => snakeCase(name)).join('/');

switch (action) {
case 'fetch': {
return http.get(path, data, meta);
}
case 'create': {
return http.post(path, data, meta);
}
case 'update': {
return http.patch(path, data, meta);
}
case 'remove': {
return http.delete(path, data, meta);
}
case 'list': {
return new Paginator(http, path, data);
}
default: {
const customAction = [path, snakeCase(action)].join('/');
return http.post(customAction, data);
}
}
};

export const createBuilder = <T>(http: Http, context: string[] = []): T => {
return new Proxy(noop, {
get: get(http, context),
apply: apply(http, context),
}) as T;
};
3 changes: 2 additions & 1 deletion src/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ describe('Config', () => {
const headers = config.createHeader({ extra: 'header' });

expect(headers.get('Authorization')).toBe('Bearer token');
expect(headers.get('Content-Type')).toBe('application/json');
expect(headers.get('extra')).toBe('header');
});

Expand Down Expand Up @@ -119,6 +118,7 @@ describe('Config', () => {
url: 'https://mastodon.social',
streamingApiUrl: 'wss://mastodon.social',
accessToken: 'token',
useInsecureWebSocketToken: true,
},
new SerializerNativeImpl(),
);
Expand Down Expand Up @@ -148,6 +148,7 @@ describe('Config', () => {
url: 'https://mastodon.social',
streamingApiUrl: 'wss://mastodon.social',
accessToken: 'token',
useInsecureWebSocketToken: true,
},
new SerializerNativeImpl(),
);
Expand Down
1 change: 0 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ export class MastoConfig {
createHeader(override: HeadersInit = {}): Headers {
const headersInit = mergeHeadersInit([
this.props.defaultRequestInit?.headers ?? {},
{ 'Content-Type': 'application/json' },
override,
]);
const headers: HeadersInit = new Headers(headersInit);
Expand Down
70 changes: 42 additions & 28 deletions src/http/base-http.ts
Original file line number Diff line number Diff line change
@@ -1,62 +1,76 @@
import type { RequestInit } from '@mastojs/ponyfills';

import type { Http, HttpRequestParams, HttpRequestResult } from './http';
import type { Encoding } from '../serializers';
import type {
Http,
HttpMetaParams,
HttpRequestParams,
HttpRequestResult,
} from './http';

export abstract class BaseHttp implements Http {
abstract request(params: HttpRequestParams): Promise<HttpRequestResult>;

get<T>(path: string, data?: unknown, init: RequestInit = {}): Promise<T> {
get<T>(
path: string,
data?: unknown,
meta: HttpMetaParams<Encoding> = {},
): Promise<T> {
return this.request({
method: 'GET',
path,
searchParams: data as Record<string, unknown>,
requestInit: {
method: 'GET',
...init,
},
...meta,
}).then((response) => response.data as T);
}

post<T>(path: string, data?: unknown, init: RequestInit = {}): Promise<T> {
post<T>(
path: string,
data?: unknown,
meta: HttpMetaParams<Encoding> = {},
): Promise<T> {
return this.request({
method: 'POST',
path,
body: data as Record<string, unknown>,
requestInit: {
method: 'POST',
...init,
},
...meta,
}).then((response) => response.data as T);
}

delete<T>(path: string, data?: unknown, init: RequestInit = {}): Promise<T> {
delete<T>(
path: string,
data?: unknown,
meta: HttpMetaParams<Encoding> = {},
): Promise<T> {
return this.request({
method: 'DELETE',
path,
body: data as Record<string, unknown>,
requestInit: {
method: 'DELETE',
...init,
},
...meta,
}).then((response) => response.data as T);
}

put<T>(path: string, data?: unknown, init: RequestInit = {}): Promise<T> {
put<T>(
path: string,
data?: unknown,
meta: HttpMetaParams<Encoding> = {},
): Promise<T> {
return this.request({
method: 'PUT',
path,
body: data as Record<string, unknown>,
requestInit: {
method: 'PUT',
...init,
},
...meta,
}).then((response) => response.data as T);
}

patch<T>(path: string, data?: unknown, init: RequestInit = {}): Promise<T> {
patch<T>(
path: string,
data?: unknown,
meta: HttpMetaParams<Encoding> = {},
): Promise<T> {
return this.request({
method: 'PATCH',
path,
body: data as Record<string, unknown>,
requestInit: {
method: 'PATCH',
...init,
},
...meta,
}).then((response) => response.data as T);
}
}
12 changes: 0 additions & 12 deletions src/http/get-content-type.spec.ts

This file was deleted.

10 changes: 0 additions & 10 deletions src/http/get-content-type.ts

This file was deleted.

12 changes: 12 additions & 0 deletions src/http/get-encoding.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Headers } from '@mastojs/ponyfills';

import { getEncoding } from './get-encoding';

test.each([
[{ 'Content-Type': 'application/json; charset=utf-8' }, 'json'],
[{ 'content-type': 'application/json; charset=utf-8' }, 'json'],
[{ 'Content-Type': 'application/json' }, 'json'],
[{}, undefined],
])('removes charset from content-type', (headers, expected) => {
expect(getEncoding(new Headers(headers))).toBe(expected);
});
25 changes: 25 additions & 0 deletions src/http/get-encoding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Headers } from '@mastojs/ponyfills';

import type { Encoding } from '../serializers';

export const getEncoding = (headers: Headers): Encoding | undefined => {
const contentType = headers.get('Content-Type')?.replace(/\s*;.*$/, '');
if (typeof contentType !== 'string') {
return;
}

switch (contentType) {
case 'application/json': {
return 'json';
}
case 'application/x-www-form-urlencoded': {
return 'form-url-encoded';
}
case 'multipart/form-data': {
return 'multipart-form';
}
default: {
return;
}
}
};
66 changes: 29 additions & 37 deletions src/http/http-native-impl.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AbortSignal } from '@mastojs/ponyfills';
import { fetch, FormData, Request, Response } from '@mastojs/ponyfills';
import type { RequestInit } from '@mastojs/ponyfills';
import { fetch, Request, Response } from '@mastojs/ponyfills';

import type { MastoConfig } from '../config';
import type { CreateErrorParams } from '../errors';
Expand All @@ -12,7 +12,7 @@ import type { Logger } from '../logger';
import type { Serializer } from '../serializers';
import type { Timeout } from '../utils';
import { BaseHttp } from './base-http';
import { getContentType } from './get-content-type';
import { getEncoding } from './get-encoding';
import type { Http, HttpRequestParams, HttpRequestResult } from './http';

export class HttpNativeImpl extends BaseHttp implements Http {
Expand All @@ -31,19 +31,19 @@ export class HttpNativeImpl extends BaseHttp implements Http {
this.logger?.info(`↑ ${request.method} ${request.url}`);
this.logger?.debug('\tbody', request.body);
const response = await fetch(request);
timeout.clear();

if (!response.ok) {
throw response;
}

const text = await response.text();
const contentType = getContentType(response.headers);
if (contentType == undefined) {
throw new MastoUnexpectedError('Content-Type is not defined');
const encoding = getEncoding(response.headers);
if (encoding == undefined) {
throw new MastoUnexpectedError(
'Unknown encoding is returned from the server',
);
}

const data = this.serializer.deserialize(contentType, text);
const data = this.serializer.deserialize(encoding, text);
this.logger?.info(`↓ ${request.method} ${request.url}`);
this.logger?.debug('\tbody', text);

Expand All @@ -54,50 +54,42 @@ export class HttpNativeImpl extends BaseHttp implements Http {
} catch (error) {
this.logger?.debug(`HTTP failed`, error);
throw await this.createError(error);
} finally {
timeout.clear();
}
}

private createRequest(params: HttpRequestParams): [Request, Timeout] {
const { path, searchParams, requestInit } = params;
const { method, path, searchParams, encoding = 'json' } = params;

const url = this.config.resolveHttpPath(path, searchParams);
const headers = this.config.createHeader(requestInit?.headers);
const [abortSignal, timeout] = this.config.createAbortSignal(
requestInit?.signal as AbortSignal,
);
const body = this.serializer.serialize(
getContentType(headers) ?? 'application/json',
params.body,
);

if (body instanceof FormData) {
// As multipart form data should contain an arbitrary boundary,
// leave Content-Type header undefined, so that fetch() API
// automatically configure Content-Type with an appropriate boundary.
headers.delete('Content-Type');
}

if (body == undefined && getContentType(headers) == 'application/json') {
// Since an empty string is not a valid JSON,
// if the body is empty and the content type is set to JSON,
// remove 'content-type:application/json' from headers
headers.delete('Content-Type');
}
const headers = this.config.createHeader(params.headers);
const [signal, timeout] = this.config.createAbortSignal(params?.signal);
const body = this.serializer.serialize(encoding, params.body);

const request = new Request(url, {
...requestInit,
const requestInit: RequestInit = {
method,
headers,
body,
signal: abortSignal,
});
signal,
};

if (typeof body === 'string' && encoding === 'json') {
headers.set('Content-Type', 'application/json');
}

if (typeof body === 'string' && encoding === 'form-url-encoded') {
headers.set('Content-Type', 'application/x-www-form-urlencoded');
}

const request = new Request(url, requestInit);
return [request, timeout];
}

private async createError(error: unknown): Promise<unknown> {
if (error instanceof Response) {
const data = this.serializer.deserialize(
getContentType(error.headers) ?? 'application/json',
getEncoding(error.headers) ?? 'json',
await error.text(),
);

Expand Down
Loading

0 comments on commit 6e61c3d

Please sign in to comment.