Skip to content

Commit 90f6c0e

Browse files
committed
chore(refactor): Refactor HTTP dispatcher
1 parent 369d62a commit 90f6c0e

9 files changed

Lines changed: 199 additions & 145 deletions

src/adapters/action/dispatcher-http.spec.ts renamed to src/adapters/action/dispatcher-http-hook-mastodon.spec.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { httpGet, HttpMockImpl, httpPost } from "../../__mocks__";
22
import { MastoHttpError, MastoTimeoutError } from "../errors";
33
import { HttpActionDispatcher } from "./dispatcher-http";
4+
import { HttpActionDispatcherHookMastodon } from "./dispatcher-http-hook-mastodon";
45

56
describe("DispatcherHttp", () => {
67
afterEach(() => {
@@ -9,7 +10,11 @@ describe("DispatcherHttp", () => {
910
});
1011

1112
it("waits for media attachment to be created", async () => {
12-
const dispatcher = new HttpActionDispatcher(new HttpMockImpl());
13+
const http = new HttpMockImpl();
14+
const dispatcher = new HttpActionDispatcher(
15+
http,
16+
new HttpActionDispatcherHookMastodon(http),
17+
);
1318

1419
httpPost.mockResolvedValueOnce({ id: "1" });
1520

@@ -35,9 +40,11 @@ describe("DispatcherHttp", () => {
3540
});
3641

3742
it("throws an error if media processing did not finish", async () => {
38-
const dispatcher = new HttpActionDispatcher(new HttpMockImpl(), {
39-
mediaTimeout: 1,
40-
});
43+
const http = new HttpMockImpl();
44+
const dispatcher = new HttpActionDispatcher(
45+
http,
46+
new HttpActionDispatcherHookMastodon(http, 1),
47+
);
4148

4249
httpPost.mockResolvedValueOnce({ id: "1" });
4350
httpGet.mockRejectedValue(
@@ -55,7 +62,11 @@ describe("DispatcherHttp", () => {
5562
});
5663

5764
it("rethrows errors for media processing", async () => {
58-
const dispatcher = new HttpActionDispatcher(new HttpMockImpl());
65+
const http = new HttpMockImpl();
66+
const dispatcher = new HttpActionDispatcher(
67+
http,
68+
new HttpActionDispatcherHookMastodon(http),
69+
);
5970

6071
httpPost.mockResolvedValueOnce({ id: "1" });
6172
httpGet.mockRejectedValueOnce(new Error("Unknown error"));
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { snakeCase } from "change-case";
2+
3+
import {
4+
type ActionDispatcherHook,
5+
type AnyAction,
6+
type Encoding,
7+
type Http,
8+
type HttpMetaParams,
9+
} from "../../interfaces";
10+
import { type mastodon } from "../../mastodon";
11+
import { sleep } from "../../utils";
12+
import { MastoHttpError, MastoTimeoutError } from "../errors";
13+
import { type HttpAction, type HttpActionType } from "./dispatcher-http";
14+
15+
function isHttpActionType(actionType: string): actionType is HttpActionType {
16+
return ["fetch", "create", "update", "remove", "list"].includes(actionType);
17+
}
18+
19+
function toHttpActionType(action: string): HttpActionType {
20+
if (isHttpActionType(action)) {
21+
return action;
22+
}
23+
24+
switch (action) {
25+
case "lookup":
26+
case "verify_credentials": {
27+
return "fetch";
28+
}
29+
case "update_credentials": {
30+
return "update";
31+
}
32+
default: {
33+
return "create";
34+
}
35+
}
36+
}
37+
38+
function inferEncoding(action: HttpActionType, path: string): Encoding {
39+
if (
40+
(action === "create" && path === "/api/v1/accounts") ||
41+
(action === "update" && path === "/api/v1/accounts/update_credentials") ||
42+
(action === "create" && path === "/api/v1/email") ||
43+
(action === "create" && path === "/api/v1/featured_tag") ||
44+
(action === "create" && path === "/api/v1/media") ||
45+
(action === "create" && path === "/api/v2/media")
46+
) {
47+
return "multipart-form";
48+
}
49+
50+
return "json";
51+
}
52+
53+
async function waitForMediaAttachment(
54+
id: string,
55+
timeout: number,
56+
http: Http,
57+
): Promise<mastodon.v1.MediaAttachment> {
58+
let media: mastodon.v1.MediaAttachment | undefined;
59+
const signal = AbortSignal.timeout(timeout);
60+
61+
while (media == undefined) {
62+
if (signal.aborted) {
63+
throw new MastoTimeoutError(`Media processing timed out of ${timeout}ms`);
64+
}
65+
66+
try {
67+
await sleep(1000);
68+
69+
const processing = await http.get<mastodon.v1.MediaAttachment>(
70+
`/api/v1/media/${id}`,
71+
);
72+
73+
if (processing.url != undefined) {
74+
media = processing;
75+
}
76+
} catch (error) {
77+
if (error instanceof MastoHttpError && error.statusCode === 404) {
78+
continue;
79+
}
80+
throw error;
81+
}
82+
}
83+
84+
return media;
85+
}
86+
87+
export class HttpActionDispatcherHookMastodon
88+
implements ActionDispatcherHook<AnyAction>
89+
{
90+
constructor(
91+
private readonly http: Http,
92+
private readonly mediaTimeout = 1000 * 60,
93+
) {}
94+
95+
beforeDispatch(action: AnyAction): HttpAction {
96+
const type = toHttpActionType(action.type);
97+
const path = isHttpActionType(action.type)
98+
? action.path
99+
: action.path + "/" + snakeCase(action.type);
100+
const encoding = inferEncoding(type, path);
101+
const meta: HttpMetaParams<Encoding> = { ...action.meta, encoding };
102+
return { type, path, data: action.data, meta };
103+
}
104+
105+
afterDispatch(action: AnyAction, result: unknown): unknown {
106+
if (action.type === "create" && action.path === "/api/v2/media") {
107+
const media = result as mastodon.v1.MediaAttachment;
108+
return waitForMediaAttachment(media.id, this.mediaTimeout, this.http);
109+
}
110+
111+
return result;
112+
}
113+
}
Lines changed: 28 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,147 +1,56 @@
1-
import { snakeCase } from "change-case";
2-
31
import {
42
type Action,
53
type ActionDispatcher,
6-
type Encoding,
4+
type ActionDispatcherHook,
75
type Http,
8-
type HttpMetaParams,
96
} from "../../interfaces";
10-
import { type mastodon } from "../../mastodon";
11-
import { sleep } from "../../utils";
12-
import { MastoHttpError, MastoTimeoutError } from "../errors";
137
import { PaginatorHttp } from "./paginator-http";
148

15-
type PrimitiveAction = "fetch" | "create" | "update" | "remove" | "list";
16-
17-
export interface HttpActionDispatcherParams {
18-
readonly mediaTimeout?: number;
19-
}
9+
export type HttpActionType = "fetch" | "create" | "update" | "remove" | "list";
10+
export type HttpAction = Action<HttpActionType>;
2011

21-
export class HttpActionDispatcher implements ActionDispatcher {
12+
export class HttpActionDispatcher implements ActionDispatcher<HttpAction> {
2213
constructor(
2314
private readonly http: Http,
24-
private readonly params: HttpActionDispatcherParams = {},
15+
private readonly hook: ActionDispatcherHook<HttpAction>,
2516
) {}
2617

27-
dispatch<T>(action: Action): T | Promise<T> {
28-
const actionType = this.toPrimitiveAction(action.type);
29-
const path = this.isPrimitiveAction(action.type)
30-
? action.path
31-
: action.path + "/" + snakeCase(action.type);
32-
const encoding = this.inferEncoding(actionType, path);
33-
const meta: HttpMetaParams<Encoding> = { ...action.meta, encoding };
18+
dispatch<T>(action: HttpAction): T | Promise<T> {
19+
if (this.hook != undefined) {
20+
action = this.hook.beforeDispatch(action);
21+
}
22+
23+
let result: T | Promise<T>;
3424

35-
switch (actionType) {
25+
switch (action.type) {
3626
case "fetch": {
37-
return this.http.get(path, action.data, meta);
27+
result = this.http.get(action.path, action.data, action.meta);
28+
break;
3829
}
3930
case "create": {
40-
if (path === "/api/v2/media") {
41-
return this.http
42-
.post<mastodon.v1.MediaAttachment>(path, action.data, meta)
43-
.then((media) => {
44-
return this.waitForMediaAttachment(media.id) as T;
45-
});
46-
}
47-
return this.http.post(path, action.data, meta);
31+
result = this.http.post(action.path, action.data, action.meta);
32+
break;
4833
}
4934
case "update": {
50-
return this.http.patch(path, action.data, meta);
35+
result = this.http.patch(action.path, action.data, action.meta);
36+
break;
5137
}
5238
case "remove": {
53-
return this.http.delete(path, action.data, meta);
54-
}
55-
case "list": {
56-
return new PaginatorHttp(this.http, path, action.data) as T;
39+
result = this.http.delete(action.path, action.data, action.meta);
40+
break;
5741
}
58-
}
59-
}
60-
61-
private isPrimitiveAction(action: string): action is PrimitiveAction {
62-
switch (action) {
63-
case "fetch":
64-
case "create":
65-
case "update":
66-
case "remove":
6742
case "list": {
68-
return true;
69-
}
70-
default: {
71-
return false;
72-
}
73-
}
74-
}
75-
76-
private toPrimitiveAction(action: string): PrimitiveAction {
77-
if (this.isPrimitiveAction(action)) {
78-
return action;
79-
}
80-
81-
switch (action) {
82-
case "lookup":
83-
case "verify_credentials": {
84-
return "fetch";
85-
}
86-
case "update_credentials": {
87-
return "update";
88-
}
89-
default: {
90-
return "create";
43+
result = new PaginatorHttp(this.http, action.path, action.data) as T;
44+
break;
9145
}
9246
}
93-
}
9447

95-
private inferEncoding(action: PrimitiveAction, path: string): Encoding {
96-
if (
97-
(action === "create" && path === "/api/v1/accounts") ||
98-
(action === "update" && path === "/api/v1/accounts/update_credentials") ||
99-
(action === "create" && path === "/api/v1/email") ||
100-
(action === "create" && path === "/api/v1/featured_tag") ||
101-
(action === "create" && path === "/api/v1/media") ||
102-
(action === "create" && path === "/api/v2/media")
103-
) {
104-
return "multipart-form";
48+
/* eslint-disable unicorn/prefer-ternary, prettier/prettier */
49+
if (result instanceof Promise) {
50+
return result.then((result) => this.hook?.afterDispatch(action, result)) as Promise<T>;
51+
} else {
52+
return this.hook.afterDispatch(action, result) as T;
10553
}
106-
107-
return "json";
108-
}
109-
110-
private get mediaTimeout(): number {
111-
return this.params.mediaTimeout ?? 60 * 1000;
54+
/* eslint-enable unicorn/prefer-ternary, prettier/prettier */
11255
}
113-
114-
private waitForMediaAttachment = async (
115-
id: string,
116-
): Promise<mastodon.v1.MediaAttachment> => {
117-
let media: mastodon.v1.MediaAttachment | undefined;
118-
const signal = AbortSignal.timeout(this.mediaTimeout);
119-
120-
while (media == undefined) {
121-
if (signal.aborted) {
122-
throw new MastoTimeoutError(
123-
`Media processing timed out of ${this.mediaTimeout}ms`,
124-
);
125-
}
126-
127-
try {
128-
await sleep(1000);
129-
130-
const processing = await this.http.get<mastodon.v1.MediaAttachment>(
131-
`/api/v1/media/${id}`,
132-
);
133-
134-
if (processing.url != undefined) {
135-
media = processing;
136-
}
137-
} catch (error) {
138-
if (error instanceof MastoHttpError && error.statusCode === 404) {
139-
continue;
140-
}
141-
throw error;
142-
}
143-
}
144-
145-
return media;
146-
};
14756
}

src/adapters/action/dispatcher-ws.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ describe("DispatcherWs", () => {
1616

1717
expect(() => {
1818
return dispatcher.dispatch({
19-
type: "unknown",
19+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
20+
type: "unknown" as any,
2021
path: "/api/v2/unknown",
2122
data: undefined,
2223
meta: {},

src/adapters/action/dispatcher-ws.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,19 @@ import {
88
import { MastoUnexpectedError } from "../errors";
99
import { WebSocketSubscription } from "../ws";
1010

11-
export class WebSocketActionDispatcher implements ActionDispatcher {
11+
type WebSocketActionType = "close" | "prepare" | "subscribe";
12+
type WebSocketAction = Action<WebSocketActionType>;
13+
14+
export class WebSocketActionDispatcher
15+
implements ActionDispatcher<WebSocketAction>
16+
{
1217
constructor(
1318
private readonly connector: WebSocketConnector,
1419
private readonly serializer: Serializer,
1520
private readonly logger?: Logger,
1621
) {}
1722

18-
dispatch<T>(action: Action): T {
23+
dispatch<T>(action: WebSocketAction): T {
1924
if (action.type === "close") {
2025
this.connector.close();
2126
return {} as T;

0 commit comments

Comments
 (0)