Skip to content

Commit 45759d6

Browse files
committed
improve(integrations/slack): sentry alert on error
1 parent 2507a9b commit 45759d6

File tree

6 files changed

+432
-66
lines changed

6 files changed

+432
-66
lines changed
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { z } from 'zod';
2+
import { Kit } from './kit';
3+
4+
export async function fetchJson<schema extends undefined | z.ZodType = undefined>(
5+
url: string,
6+
requestInit?: RequestInit,
7+
schema?: schema,
8+
): Promise<
9+
(schema extends z.ZodType ? z.infer<schema> : Kit.Json.Value) | FetchJsonErrors.FetchJsonErrors
10+
> {
11+
const response = await fetch(url, requestInit)
12+
// @see https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch#exceptions
13+
.catch(Kit.oneOf(error => error instanceof TypeError || error instanceof DOMException));
14+
15+
if (response instanceof TypeError) {
16+
return new FetchJsonErrors.FetchJsonRequestTypeError({ requestInit }, response);
17+
}
18+
19+
if (response instanceof DOMException) {
20+
return new FetchJsonErrors.FetchJsonRequestNetworkError({}, response);
21+
}
22+
23+
const json = await response
24+
.json()
25+
.then(value => value as Kit.Json.Value)
26+
// @see https://developer.mozilla.org/en-US/docs/Web/API/Response/json#exceptions
27+
.catch(
28+
Kit.oneOf(
29+
error =>
30+
error instanceof SyntaxError ||
31+
error instanceof TypeError ||
32+
error instanceof DOMException,
33+
),
34+
);
35+
36+
if (json instanceof DOMException) {
37+
return new FetchJsonErrors.FetchJsonRequestNetworkError({}, json);
38+
}
39+
40+
if (json instanceof TypeError) {
41+
return new FetchJsonErrors.FetchJsonResponseTypeError({ response }, json);
42+
}
43+
44+
if (json instanceof SyntaxError) {
45+
return new FetchJsonErrors.FetchJsonResponseSyntaxError({ response }, json);
46+
}
47+
48+
if (schema) {
49+
const result = schema.safeParse(json);
50+
if (!result.success) {
51+
return new FetchJsonErrors.FetchJsonResponseSchemaError(
52+
{ response, json, schema },
53+
result.error,
54+
);
55+
}
56+
return result.data as any; // z.infer<Exclude<schema, undefined>>;
57+
}
58+
59+
return json as any; // Kit.Json.Value;
60+
}
61+
62+
// =================================
63+
// Error Classes
64+
// =================================
65+
66+
// eslint-disable-next-line @typescript-eslint/no-namespace
67+
export namespace FetchJsonErrors {
68+
export type FetchJsonErrors = FetchJsonResponseErrors | FetchJsonRequestErrors;
69+
70+
// --------------------------------
71+
// Response Error Classes
72+
// --------------------------------
73+
74+
export type FetchJsonRequestErrors = FetchJsonRequestTypeError | FetchJsonRequestNetworkError;
75+
76+
export class FetchJsonRequestNetworkError extends Kit.Errors.ContextualError<
77+
'FetchJsonRequestNetworkError',
78+
{},
79+
DOMException
80+
> {
81+
message = 'Network failure.';
82+
}
83+
84+
export class FetchJsonRequestTypeError extends Kit.Errors.ContextualError<
85+
'FetchJsonRequestTypeError',
86+
{ requestInit?: RequestInit },
87+
TypeError
88+
> {
89+
message = 'Invalid request.';
90+
}
91+
92+
// --------------------------------
93+
// Response Error Classes
94+
// --------------------------------
95+
96+
export abstract class FetchJsonResponseError<
97+
$Name extends string,
98+
$Context extends {
99+
response: Response;
100+
},
101+
$Cause extends z.ZodError | SyntaxError | TypeError | DOMException,
102+
> extends Kit.Errors.ContextualError<$Name, $Context, $Cause> {
103+
message = 'Invalid response.';
104+
}
105+
106+
export type FetchJsonResponseErrors =
107+
| FetchJsonResponseSyntaxError
108+
| FetchJsonResponseSchemaError
109+
| FetchJsonResponseTypeError;
110+
111+
export class FetchJsonResponseTypeError extends FetchJsonResponseError<
112+
'FetchJsonResponseTypeError',
113+
{ response: Response },
114+
TypeError
115+
> {
116+
message = 'Response is malformed.';
117+
}
118+
119+
export class FetchJsonResponseSyntaxError extends FetchJsonResponseError<
120+
'FetchJsonResponseSyntaxError',
121+
{ response: Response },
122+
SyntaxError
123+
> {
124+
message = 'Response body is not valid JSON.';
125+
}
126+
127+
export class FetchJsonResponseSchemaError extends FetchJsonResponseError<
128+
'FetchJsonResponseSchemaError',
129+
{ response: Response; json: Kit.Json.Value; schema: z.ZodType },
130+
z.ZodError
131+
> {
132+
message = 'Response body JSON violates the schema.';
133+
}
134+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// eslint-disable-next-line @typescript-eslint/no-namespace
2+
export namespace Errors {
3+
export abstract class ContextualError<
4+
$Name extends string = string,
5+
$Context extends object = object,
6+
$Cause extends Error | undefined = Error | undefined,
7+
> extends Error {
8+
public name: $Name;
9+
public context: $Context;
10+
public cause: $Cause;
11+
constructor(
12+
...args: undefined extends $Cause
13+
? [context: $Context, cause?: $Cause]
14+
: [context: $Context, cause: $Cause]
15+
) {
16+
const [context, cause] = args;
17+
super('Something went wrong.', { cause });
18+
this.name = this.constructor.name as $Name;
19+
this.context = context;
20+
this.cause = cause as $Cause;
21+
}
22+
}
23+
24+
export class TypedAggregateError<$Error extends Error> extends AggregateError {
25+
constructor(
26+
public errors: $Error[],
27+
message?: string,
28+
) {
29+
super(errors, message);
30+
this.name = this.constructor.name;
31+
}
32+
}
33+
}

packages/web/app/src/lib/kit/helpers.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,20 @@ export const tryOr = <$PrimaryResult, $FallbackResult>(
88
return fallback();
99
}
1010
};
11+
12+
export const oneOf = <type extends readonly unknown[]>(
13+
...guards: OneOfCheck<type>
14+
): ((value: unknown) => type[number]) => {
15+
return (value: unknown) => {
16+
for (const guard of guards) {
17+
if (guard(value)) {
18+
return value;
19+
}
20+
}
21+
throw new Error(`Unexpected value received by oneOf: ${value}`);
22+
};
23+
};
24+
25+
type OneOfCheck<types extends readonly unknown[]> = {
26+
[index in keyof types]: (value: unknown) => value is types[index];
27+
};

packages/web/app/src/lib/kit/index_.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from './types/headers';
33
export * from './helpers';
44
export * from './json';
55
export * from './zod-helpers';
6+
export * from './errors';

packages/web/app/src/lib/slack-api.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { stringify } from 'node:querystring';
2+
import { z } from 'zod';
3+
import { fetchJson } from './fetch-json';
4+
5+
// eslint-disable-next-line @typescript-eslint/no-namespace
6+
export namespace SlackAPI {
7+
// ==================================
8+
// Data Utilities
9+
// ==================================
10+
11+
export const createOauth2AuthorizeUrl = (parameters: {
12+
state: string;
13+
clientId: string;
14+
redirectUrl: string;
15+
scopes: string[];
16+
}) => {
17+
const url = new URL('https://slack.com/oauth/v2/authorize');
18+
const searchParams = new URLSearchParams({
19+
scope: parameters.scopes.join(','),
20+
client_id: parameters.clientId,
21+
redirect_uri: parameters.redirectUrl,
22+
state: parameters.state,
23+
});
24+
25+
url.search = searchParams.toString();
26+
return url.toString();
27+
};
28+
29+
// ==================================
30+
// Request Methods
31+
// ==================================
32+
33+
// ----------------------------------
34+
// OAuth2AccessResult
35+
// ----------------------------------
36+
37+
const OAuth2AccessResult = z.discriminatedUnion('ok', [
38+
z.object({
39+
ok: z.literal(true),
40+
access_token: z.string(),
41+
}),
42+
z.object({
43+
ok: z.literal(false),
44+
error: z.string(),
45+
}),
46+
]);
47+
48+
export type OAuth2AccessResult = z.infer<typeof OAuth2AccessResult>;
49+
50+
export interface OAuth2AccessPayload {
51+
clientId: string;
52+
clientSecret: string;
53+
code: string;
54+
}
55+
56+
export async function requestOauth2Access(payload: OAuth2AccessPayload) {
57+
return fetchJson(
58+
'https://slack.com/api/oauth.v2.access',
59+
{
60+
method: 'POST',
61+
headers: {
62+
'content-type': 'application/x-www-form-urlencoded',
63+
},
64+
body: stringify({
65+
client_id: payload.clientId,
66+
client_secret: payload.clientSecret,
67+
code: payload.code,
68+
}),
69+
},
70+
OAuth2AccessResult,
71+
);
72+
}
73+
}

0 commit comments

Comments
 (0)