From 8cc82f4ecc94e2a03bc65b02efca61ab535293c6 Mon Sep 17 00:00:00 2001 From: Ryo Igarashi Date: Wed, 16 Oct 2024 11:42:51 +0900 Subject: [PATCH] chore: Refactor Mastodon specific logic with hook API --- ...stodon.spec.ts => dispatcher-http.spec.ts} | 12 +-- src/adapters/action/dispatcher-http.ts | 35 ++++---- src/adapters/clients.ts | 47 ++++++++-- .../hook-action-dispatcher-mastodon.ts} | 24 ++--- src/adapters/hook/hook-http-mastodon.spec.ts | 17 ++++ src/adapters/hook/hook-http-mastodon.ts | 19 ++++ src/adapters/hook/index.ts | 2 + src/adapters/http/http-native-impl.ts | 26 ++++-- src/interfaces/action.ts | 10 +-- src/interfaces/hook.ts | 90 +++++++++++++++++++ src/interfaces/index.ts | 5 +- 11 files changed, 223 insertions(+), 64 deletions(-) rename src/adapters/action/{dispatcher-http-hook-mastodon.spec.ts => dispatcher-http.spec.ts} (89%) rename src/adapters/{action/dispatcher-http-hook-mastodon.ts => hook/hook-action-dispatcher-mastodon.ts} (84%) create mode 100644 src/adapters/hook/hook-http-mastodon.spec.ts create mode 100644 src/adapters/hook/hook-http-mastodon.ts create mode 100644 src/adapters/hook/index.ts create mode 100644 src/interfaces/hook.ts diff --git a/src/adapters/action/dispatcher-http-hook-mastodon.spec.ts b/src/adapters/action/dispatcher-http.spec.ts similarity index 89% rename from src/adapters/action/dispatcher-http-hook-mastodon.spec.ts rename to src/adapters/action/dispatcher-http.spec.ts index db93c3291..723923ab6 100644 --- a/src/adapters/action/dispatcher-http-hook-mastodon.spec.ts +++ b/src/adapters/action/dispatcher-http.spec.ts @@ -1,9 +1,9 @@ import { httpGet, HttpMockImpl, httpPost } from "../../__mocks__"; import { MastoHttpError, MastoTimeoutError } from "../errors"; +import { ActionDispatcherHookMastodon } from "../hook/hook-action-dispatcher-mastodon"; import { HttpActionDispatcher } from "./dispatcher-http"; -import { HttpActionDispatcherHookMastodon } from "./dispatcher-http-hook-mastodon"; -describe("DispatcherHttp", () => { +describe("HttpActionDispatcher", () => { afterEach(() => { httpGet.mockClear(); httpPost.mockClear(); @@ -13,7 +13,7 @@ describe("DispatcherHttp", () => { const http = new HttpMockImpl(); const dispatcher = new HttpActionDispatcher( http, - new HttpActionDispatcherHookMastodon(http), + new ActionDispatcherHookMastodon(http), ); httpPost.mockResolvedValueOnce({ id: "1" }); @@ -43,7 +43,7 @@ describe("DispatcherHttp", () => { const http = new HttpMockImpl(); const dispatcher = new HttpActionDispatcher( http, - new HttpActionDispatcherHookMastodon(http, 1), + new ActionDispatcherHookMastodon(http, 1), ); httpPost.mockResolvedValueOnce({ id: "1" }); @@ -65,7 +65,7 @@ describe("DispatcherHttp", () => { const http = new HttpMockImpl(); const dispatcher = new HttpActionDispatcher( http, - new HttpActionDispatcherHookMastodon(http), + new ActionDispatcherHookMastodon(http), ); httpPost.mockResolvedValueOnce({ id: "1" }); @@ -85,7 +85,7 @@ describe("DispatcherHttp", () => { const http = new HttpMockImpl(); const dispatcher = new HttpActionDispatcher( http, - new HttpActionDispatcherHookMastodon(http), + new ActionDispatcherHookMastodon(http), ); httpPost.mockResolvedValueOnce({ id: "1" }); diff --git a/src/adapters/action/dispatcher-http.ts b/src/adapters/action/dispatcher-http.ts index e8b9b668b..3af54e882 100644 --- a/src/adapters/action/dispatcher-http.ts +++ b/src/adapters/action/dispatcher-http.ts @@ -1,29 +1,23 @@ import { - type Action, type ActionDispatcher, type ActionDispatcherHook, + type AnyAction, type Http, } from "../../interfaces"; import { PaginatorHttp } from "./paginator-http"; -export type HttpActionType = "fetch" | "create" | "update" | "remove" | "list"; -export type HttpAction = Action; - -export class HttpActionDispatcher implements ActionDispatcher { +export class HttpActionDispatcher implements ActionDispatcher { constructor( private readonly http: Http, - private readonly hook: ActionDispatcherHook, + private readonly hook?: ActionDispatcherHook, ) {} - dispatch(action: HttpAction): T | Promise { - if (this.hook != undefined) { - action = this.hook.beforeDispatch(action); + dispatch(action: AnyAction): T | Promise { + if (this.hook) { + action = this.hook.before(action); } - let result = this.hook.dispatch(action) as T | Promise | false; - if (result !== false) { - return result; - } + let result!: T | Promise; switch (action.type) { case "fetch": { @@ -48,12 +42,15 @@ export class HttpActionDispatcher implements ActionDispatcher { } } - /* eslint-disable unicorn/prefer-ternary, prettier/prettier */ - if (result instanceof Promise) { - return result.then((result) => this.hook?.afterDispatch(action, result)) as Promise; - } else { - return this.hook.afterDispatch(action, result) as T; + if (this.hook) { + /* eslint-disable unicorn/prefer-ternary, prettier/prettier */ + if (result instanceof Promise) { + return result.then((result) => this.hook?.after(result, action)) as Promise; + } else { + return this.hook?.after(result, action) as T; + } } - /* eslint-enable unicorn/prefer-ternary, prettier/prettier */ + + return result; } } diff --git a/src/adapters/clients.ts b/src/adapters/clients.ts index 06acbe46f..786f193c5 100644 --- a/src/adapters/clients.ts +++ b/src/adapters/clients.ts @@ -1,17 +1,23 @@ -import { type LogType } from "../interfaces"; +import { + type ActionDispatcherHook, + combine, + type Hook, + type HttpHook, + type LogType, +} from "../interfaces"; import { type mastodon } from "../mastodon"; import { createActionProxy, HttpActionDispatcher, WebSocketActionDispatcher, } from "./action"; -import { HttpActionDispatcherHookMastodon } from "./action/dispatcher-http-hook-mastodon"; import { HttpConfigImpl, type MastoHttpConfigProps, WebSocketConfigImpl, type WebSocketConfigProps, } from "./config"; +import { ActionDispatcherHookMastodon, HttpHookMastodon } from "./hook"; import { HttpNativeImpl } from "./http"; import { createLogger } from "./logger"; import { SerializerNativeImpl } from "./serializers"; @@ -31,15 +37,40 @@ interface LogConfigProps { readonly log?: LogType; } +interface HookProps { + readonly use?: readonly Hook[]; +} + export const createRestAPIClient = ( - props: MastoHttpConfigProps & LogConfigProps, + props: MastoHttpConfigProps & LogConfigProps & HookProps, ): mastodon.rest.Client => { + const use = props.use ?? []; + const serializer = new SerializerNativeImpl(); const config = new HttpConfigImpl(props, serializer); const logger = createLogger(props.log); - const http = new HttpNativeImpl(serializer, config, logger); - const hook = new HttpActionDispatcherHookMastodon(http); - const actionDispatcher = new HttpActionDispatcher(http, hook); + + const http = new HttpNativeImpl( + serializer, + config, + logger, + combine([ + ...use.filter((hook): hook is HttpHook => hook.type === "Http"), + new HttpHookMastodon(), + ]), + ); + + const actionDispatcher = new HttpActionDispatcher( + http, + combine([ + ...use.filter( + (hook): hook is ActionDispatcherHook => + hook.type === "ActionDispatcher", + ), + new ActionDispatcherHookMastodon(http), + ]), + ); + const actionProxy = createActionProxy(actionDispatcher, { context: ["api"], }) as mastodon.rest.Client; @@ -53,8 +84,8 @@ export const createOAuthAPIClient = ( const config = new HttpConfigImpl(props, serializer); const logger = createLogger(props.log); const http = new HttpNativeImpl(serializer, config, logger); - const hook = new HttpActionDispatcherHookMastodon(http); - const actionDispatcher = new HttpActionDispatcher(http, hook); + const actionDispatcherHook = new ActionDispatcherHookMastodon(http); + const actionDispatcher = new HttpActionDispatcher(http, actionDispatcherHook); const actionProxy = createActionProxy(actionDispatcher, { context: ["oauth"], }) as mastodon.oauth.Client; diff --git a/src/adapters/action/dispatcher-http-hook-mastodon.ts b/src/adapters/hook/hook-action-dispatcher-mastodon.ts similarity index 84% rename from src/adapters/action/dispatcher-http-hook-mastodon.ts rename to src/adapters/hook/hook-action-dispatcher-mastodon.ts index 72ee94789..4b6c91243 100644 --- a/src/adapters/action/dispatcher-http-hook-mastodon.ts +++ b/src/adapters/hook/hook-action-dispatcher-mastodon.ts @@ -10,7 +10,8 @@ import { import { type mastodon } from "../../mastodon"; import { isRecord, sleep } from "../../utils"; import { MastoHttpError, MastoTimeoutError } from "../errors"; -import { type HttpAction, type HttpActionType } from "./dispatcher-http"; + +type HttpActionType = "fetch" | "create" | "update" | "remove" | "list"; function isHttpActionType(actionType: string): actionType is HttpActionType { return ["fetch", "create", "update", "remove", "list"].includes(actionType); @@ -84,15 +85,15 @@ async function waitForMediaAttachment( return media; } -export class HttpActionDispatcherHookMastodon - implements ActionDispatcherHook -{ +export class ActionDispatcherHookMastodon implements ActionDispatcherHook { + readonly type = "ActionDispatcher"; + constructor( private readonly http: Http, private readonly mediaTimeout = 1000 * 60, ) {} - beforeDispatch(action: AnyAction): HttpAction { + before(action: AnyAction): AnyAction { const type = toHttpActionType(action.type); const path = isHttpActionType(action.type) ? action.path @@ -103,18 +104,7 @@ export class HttpActionDispatcherHookMastodon return { type, path, data: action.data, meta }; } - dispatch(action: AnyAction): false | Promise { - if ( - action.type === "update" && - action.path === "/api/v1/accounts/update_credentials" - ) { - return this.http.patch(action.path, action.data, action.meta); - } - - return false; - } - - afterDispatch(action: AnyAction, result: unknown): unknown { + after(result: unknown, action: AnyAction): unknown { if (action.type === "create" && action.path === "/api/v2/media") { const media = result as mastodon.v1.MediaAttachment; if (isRecord(action.data) && action.data?.skipPolling === true) { diff --git a/src/adapters/hook/hook-http-mastodon.spec.ts b/src/adapters/hook/hook-http-mastodon.spec.ts new file mode 100644 index 000000000..58a26a5a3 --- /dev/null +++ b/src/adapters/hook/hook-http-mastodon.spec.ts @@ -0,0 +1,17 @@ +import assert from "node:assert"; + +import { HttpHookMastodon } from "./hook-http-mastodon"; + +describe("hookHttpMastodon", () => { + it("returns a new Request with method PATCH if the request is PUT and the URL ends with /api/v1/accounts/update_credentials", async () => { + const request = new Request( + "https://example.com/api/v1/accounts/update_credentials", + { method: "PUT" }, + ); + const hook = new HttpHookMastodon(); + const result = await hook.before(request); + assert(result instanceof Request); + expect(result).toBeInstanceOf(Request); + expect(result.method).toBe("PATCH"); + }); +}); diff --git a/src/adapters/hook/hook-http-mastodon.ts b/src/adapters/hook/hook-http-mastodon.ts new file mode 100644 index 000000000..3d5ee6179 --- /dev/null +++ b/src/adapters/hook/hook-http-mastodon.ts @@ -0,0 +1,19 @@ +import { type HttpHook } from "../../interfaces/hook"; + +export class HttpHookMastodon implements HttpHook { + readonly type = "Http"; + + async before(request: Request): Promise { + if ( + request.method === "PUT" && + request.url.endsWith("/api/v1/accounts/update_credentials") + ) { + return new Request(request, { method: "PATCH" }); + } + return request; + } + + async after(response: Response): Promise { + return response; + } +} diff --git a/src/adapters/hook/index.ts b/src/adapters/hook/index.ts new file mode 100644 index 000000000..e3badedc3 --- /dev/null +++ b/src/adapters/hook/index.ts @@ -0,0 +1,2 @@ +export * from "./hook-action-dispatcher-mastodon"; +export * from "./hook-http-mastodon"; diff --git a/src/adapters/http/http-native-impl.ts b/src/adapters/http/http-native-impl.ts index 6beb843bf..4790bc4bf 100644 --- a/src/adapters/http/http-native-impl.ts +++ b/src/adapters/http/http-native-impl.ts @@ -1,6 +1,7 @@ import { type Http, type HttpConfig, + type HttpHook, type HttpRequestParams, type HttpRequestResult, type Logger, @@ -20,20 +21,31 @@ export class HttpNativeImpl extends BaseHttp implements Http { private readonly serializer: Serializer, private readonly config: HttpConfig, private readonly logger?: Logger, + private readonly hook?: HttpHook, ) { super(); } async request(params: HttpRequestParams): Promise { - const request = this.createRequest(params); + let request = this.createRequest(params); + + if (this.hook) { + request = await this.hook.before(request); + } + + this.logger?.log("info", `↑ ${request.method} ${request.url}`); + this.logger?.log("debug", "\tbody", { + encoding: params.encoding, + body: params.body, + }); try { - this.logger?.log("info", `↑ ${request.method} ${request.url}`); - this.logger?.log("debug", "\tbody", { - encoding: params.encoding, - body: params.body, - }); - const response = await fetch(request); + let response = await fetch(request); + + if (this.hook) { + response = await this.hook.after(response); + } + if (!response.ok) { throw response; } diff --git a/src/interfaces/action.ts b/src/interfaces/action.ts index 14c5f39ce..ab398b96b 100644 --- a/src/interfaces/action.ts +++ b/src/interfaces/action.ts @@ -15,8 +15,8 @@ export interface ActionDispatcher { [Symbol.dispose]?(): void; } -export interface ActionDispatcherHook { - beforeDispatch(action: T): T; - dispatch(action: T): U | Promise | false; - afterDispatch(action: T, result: U | Promise): U; -} +// export interface ActionDispatcherHook { +// beforeDispatch(action: T): T; +// dispatch(action: T): U | Promise | false; +// afterDispatch(action: T, result: U | Promise): U; +// } diff --git a/src/interfaces/hook.ts b/src/interfaces/hook.ts new file mode 100644 index 000000000..fb912fea5 --- /dev/null +++ b/src/interfaces/hook.ts @@ -0,0 +1,90 @@ +import { type AnyAction } from "./action"; + +export type BeforeFn = (...args: Ctx) => Result; + +export type AfterFn = (...args: Ctx) => Result; + +export interface BaseHook< + T, + BCtx extends unknown[], + BRes, + ACtx extends unknown[], + ARes, +> { + type: T; + before: BeforeFn; + after: AfterFn; +} + +export type AnyHook = BaseHook; + +export type HttpHook = BaseHook< + "Http", + [Request], + Promise, + [Response], + Promise +>; + +export type ActionDispatcherHook = BaseHook< + "ActionDispatcher", + [AnyAction], + AnyAction, + [unknown, AnyAction], + unknown +>; + +export type Hook = HttpHook | ActionDispatcherHook; + +function combineHttpHooks(hooks: HttpHook[]): HttpHook { + return { + type: "Http", + before: async (request: Request) => { + for (const hook of hooks) { + request = await hook.before(request); + } + return request; + }, + after: async (response: Response) => { + for (const hook of hooks) { + response = await hook.after(response); + } + return response; + }, + }; +} + +function combineActionDispatcherHook( + hooks: ActionDispatcherHook[], +): ActionDispatcherHook { + return { + type: "ActionDispatcher", + before: (action: AnyAction) => { + for (const hook of hooks) { + action = hook.before(action); + } + return action; + }, + after: (result: unknown, action: AnyAction) => { + for (const hook of hooks) { + result = hook.after(result, action); + } + return result; + }, + }; +} + +export function combine(hooks: T[]): T { + if (hooks.length === 0) { + throw new Error("No hooks provided"); + } + + const firstHook = hooks[0]; + if (firstHook.type === "Http") { + return combineHttpHooks(hooks as HttpHook[]) as T; + } else if (firstHook.type === "ActionDispatcher") { + return combineActionDispatcherHook(hooks as ActionDispatcherHook[]) as T; + } else { + throw new Error("Unknown hook type"); + } +} diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 53dafc7c6..cff6de9f3 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -1,6 +1,7 @@ +export * from "./action"; +export * from "./config"; +export * from "./hook"; export * from "./http"; export * from "./logger"; export * from "./serializer"; -export * from "./config"; -export * from "./action"; export * from "./ws";