Skip to content

Commit

Permalink
refactor: Move common code out to util (#466)
Browse files Browse the repository at this point in the history
* wip

* Refactor code

* Refactor some code

* Update api
  • Loading branch information
amaury1093 authored Dec 4, 2023
1 parent 0fa202d commit ef97a86
Show file tree
Hide file tree
Showing 6 changed files with 197 additions and 160 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"@geist-ui/react": "^2.1.5",
"@geist-ui/react-icons": "^1.0.1",
"@hcaptcha/react-hcaptcha": "^1.4.4",
"@reacherhq/api": "^0.3.4",
"@reacherhq/api": "^0.3.5",
"@sendinblue/client": "^3.3.1",
"@sentry/nextjs": "^7.45.0",
"@stripe/stripe-js": "^1.52.0",
Expand Down
163 changes: 9 additions & 154 deletions src/pages/api/v0/check_email.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,18 @@
import type { CheckEmailInput, CheckEmailOutput } from '@reacherhq/api';
import { User } from '@supabase/supabase-js';
import axios, { AxiosError } from 'axios';
import Cors from 'cors';
import { addMonths, differenceInMilliseconds, parseISO } from 'date-fns';
import { NextApiRequest, NextApiResponse } from 'next';
import { RateLimiterRes } from 'rate-limiter-flexible';
import { v4 } from 'uuid';

import { setRateLimitHeaders } from '../../../util/helpers';
import { sendinblueApi, sendinblueDateFormat } from '../../../util/sendinblue';
import { checkUserInDB, cors } from '../../../util/api';
import { getReacherBackends, ReacherBackend } from '../../../util/backend';
import { updateSendinblue } from '../../../util/sendinblue';
import { sentryException } from '../../../util/sentry';
import { subApiMaxCalls } from '../../../util/subs';
import { SupabaseCall, SupabaseUser } from '../../../util/supabaseClient';
import {
getActiveSubscription,
getApiUsageServer,
getUserByApiToken,
supabaseAdmin,
} from '../../../util/supabaseServer';
import { supabaseAdmin } from '../../../util/supabaseServer';

// Reacher only exposes one endpoint right now, so we're hardcoding it.
const ENDPOINT = `/v0/check_email`;

// Helper method to wait for a middleware to execute before continuing
// And to throw an error when an error happens in a middleware
//
// Copied from https://github.com/vercel/next.js/blob/a342fba00ac0fa57f2685665be52bf42649881b6/examples/api-routes-cors/lib/init-middleware.js
// MIT License Copyright (c) 2021 Vercel, Inc.
function initMiddleware<R>(
middleware: (
req: NextApiRequest,
res: NextApiResponse,
cb: (r: R) => void
) => void
): (req: NextApiRequest, res: NextApiResponse) => Promise<R> {
return (req, res) =>
new Promise((resolve, reject) => {
middleware(req, res, (result) => {
if (result instanceof Error) {
return reject(result);
}
return resolve(result);
});
});
}

// Initialize the cors middleware
const cors = initMiddleware(
// You can read more about the available options here: https://github.com/expressjs/cors#configuration-options
Cors({
// Only allow requests with GET, POST, OPTIONS and HEAD
methods: ['GET', 'POST', 'OPTIONS', 'HEAD'],
})
);

const checkEmail = async (
req: NextApiRequest,
res: NextApiResponse
Expand All @@ -67,50 +26,12 @@ const checkEmail = async (
return;
}

try {
const token = req.headers.authorization || req.headers.Authorization;

if (typeof token !== 'string') {
throw new Error('Expected API token in the Authorization header.');
}

const user = await getUserByApiToken(token);
if (!user) {
res.status(401).json({ error: 'User not found' });
return;
}

// Safe to type cast here, as we only need the `id` field below.
const authUser = { id: user.id } as User;

// TODO instead of doing another round of network call, we should do a
// join for subscriptions and API calls inside getUserByApiToken.
const sub = await getActiveSubscription(authUser);
const used = await getApiUsageServer(user, sub);

// Set rate limit headers.
const now = new Date();
const nextReset = sub
? typeof sub.current_period_end === 'string'
? parseISO(sub.current_period_end)
: sub.current_period_end
: addMonths(now, 1);
const msDiff = differenceInMilliseconds(nextReset, now);
const max = subApiMaxCalls(sub);
setRateLimitHeaders(
res,
new RateLimiterRes(max - used - 1, msDiff, used, undefined), // 1st arg has -1, because we just consumed 1 email.
max
);

if (used > max) {
res.status(429).json({
error: 'Too many requests this month. Please upgrade your Reacher plan to make more requests.',
});

return;
}
const { user, sentResponse } = await checkUserInDB(req, res);
if (sentResponse) {
return;
}

try {
await tryAllBackends(req, res, user);

// Update the LAST_API_CALL field in Sendinblue.
Expand Down Expand Up @@ -251,69 +172,3 @@ async function makeSingleBackendCall(

return result.data;
}

interface ReacherBackend {
/**
* Is bulk email verification enabled
*/
hasBulk: boolean;
/**
* IP address of the backend (if known).
*/
ip?: string;
/**
* Human-readable name of the backend.
*/
name: string;
/**
* Backend URL.
*/
url: string;
}

// Cache the result of the getReacherBackends parsing function.
let cachedReacherBackends: ReacherBackend[] | undefined;

/**
* Get all of Reacher's internal backends, as an array.
*/
function getReacherBackends(): ReacherBackend[] {
if (cachedReacherBackends) {
return cachedReacherBackends;
}
cachedReacherBackends;
if (!process.env.RCH_BACKENDS) {
throw new Error('Got empty RCH_BACKENDS env var.');
}

// RCH_BACKENDS is an array of all Reacher's internal backends.
cachedReacherBackends = JSON.parse(
process.env.RCH_BACKENDS
) as ReacherBackend[];

return cachedReacherBackends;
}

/**
* Update the LAST_API_CALL field on Sendinblue.
*/
async function updateSendinblue(user: SupabaseUser): Promise<void> {
if (!user.sendinblue_contact_id) {
sentryException(
new Error(`User ${user.id} does not have a sendinblue_contact_id`)
);
return;
}

return sendinblueApi
.updateContact(user.sendinblue_contact_id, {
attributes: {
SUPABASE_UUID: user.id, // This should be set already, but we re-set it just in case.
LAST_API_CALL: sendinblueDateFormat(new Date()),
},
})
.then(() => {
/* do nothing */
})
.catch(sentryException);
}
114 changes: 114 additions & 0 deletions src/util/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { User } from '@supabase/supabase-js';
import Cors from 'cors';
import { addMonths, differenceInMilliseconds, parseISO } from 'date-fns';
import { NextApiRequest, NextApiResponse } from 'next';
import { RateLimiterRes } from 'rate-limiter-flexible';

import { setRateLimitHeaders } from './helpers';
import { subApiMaxCalls } from './subs';
import { SupabaseUser } from './supabaseClient';
import {
getActiveSubscription,
getApiUsageServer,
getUserByApiToken,
} from './supabaseServer';

// Helper method to wait for a middleware to execute before continuing
// And to throw an error when an error happens in a middleware
//
// Copied from https://github.com/vercel/next.js/blob/a342fba00ac0fa57f2685665be52bf42649881b6/examples/api-routes-cors/lib/init-middleware.js
// MIT License Copyright (c) 2021 Vercel, Inc.
function initMiddleware<R>(
middleware: (
req: NextApiRequest,
res: NextApiResponse,
cb: (r: R) => void
) => void
): (req: NextApiRequest, res: NextApiResponse) => Promise<R> {
return (req, res) =>
new Promise((resolve, reject) => {
middleware(req, res, (result) => {
if (result instanceof Error) {
return reject(result);
}
return resolve(result);
});
});
}

// Initialize the cors middleware
export const cors = initMiddleware(
// You can read more about the available options here: https://github.com/expressjs/cors#configuration-options
Cors({
// Only allow requests with GET, POST, OPTIONS and HEAD
methods: ['GET', 'POST', 'OPTIONS', 'HEAD'],
})
);

type CheckUserReturnType =
| {
user?: undefined;
sentResponse: true;
}
| {
user: SupabaseUser;
sentResponse: false;
};

/**
* Checks the user's authorization token and retrieves user information.
*
* @param req - The NextApiRequest object.
* @param res - The NextApiResponse object.
* @returns A Promise that resolves to a CheckUserReturnType object.
* @throws An error if the API token is missing or invalid.
*/
export async function checkUserInDB(
req: NextApiRequest,
res: NextApiResponse
): Promise<CheckUserReturnType> {
const token = req.headers.authorization || req.headers.Authorization;

if (typeof token !== 'string') {
throw new Error('Expected API token in the Authorization header.');
}

const user = await getUserByApiToken(token);
if (!user) {
res.status(401).json({ error: 'User not found' });
return { sentResponse: true };
}

// Safe to type cast here, as we only need the `id` field below.
const authUser = { id: user.id } as User;

// TODO instead of doing another round of network call, we should do a
// join for subscriptions and API calls inside getUserByApiToken.
const sub = await getActiveSubscription(authUser);
const used = await getApiUsageServer(user, sub);

// Set rate limit headers.
const now = new Date();
const nextReset = sub
? typeof sub.current_period_end === 'string'
? parseISO(sub.current_period_end)
: sub.current_period_end
: addMonths(now, 1);
const msDiff = differenceInMilliseconds(nextReset, now);
const max = subApiMaxCalls(sub);
setRateLimitHeaders(
res,
new RateLimiterRes(max - used - 1, msDiff, used, undefined), // 1st arg has -1, because we just consumed 1 email.
max
);

if (used > max) {
res.status(429).json({
error: 'Too many requests this month. Please upgrade your Reacher plan to make more requests.',
});

return { sentResponse: true };
}

return { user, sentResponse: false };
}
41 changes: 41 additions & 0 deletions src/util/backend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export interface ReacherBackend {
/**
* Is bulk email verification enabled
*/
hasBulk: boolean;
/**
* IP address of the backend (if known).
*/
ip?: string;
/**
* Human-readable name of the backend.
*/
name: string;
/**
* Backend URL.
*/
url: string;
}

// Cache the result of the getReacherBackends parsing function.
let cachedReacherBackends: ReacherBackend[] | undefined;

/**
* Get all of Reacher's internal backends, as an array.
*/
export function getReacherBackends(): ReacherBackend[] {
if (cachedReacherBackends) {
return cachedReacherBackends;
}
cachedReacherBackends;
if (!process.env.RCH_BACKENDS) {
throw new Error('Got empty RCH_BACKENDS env var.');
}

// RCH_BACKENDS is an array of all Reacher's internal backends.
cachedReacherBackends = JSON.parse(
process.env.RCH_BACKENDS
) as ReacherBackend[];

return cachedReacherBackends;
}
29 changes: 28 additions & 1 deletion src/util/sendinblue.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { ContactsApi, ContactsApiApiKeys } from '@sendinblue/client';
import { format } from 'date-fns';

import { sentryException } from './sentry';
import { SupabaseUser } from './supabaseClient';

export const sendinblueApi = new ContactsApi();

sendinblueApi.setApiKey(
Expand All @@ -11,6 +14,30 @@ sendinblueApi.setApiKey(
/**
* Format a Date into the format accepted by Sendinblue, which is yyyy-MM-dd.
*/
export function sendinblueDateFormat(d: Date): string {
function sendinblueDateFormat(d: Date): string {
return format(d, 'yyyy-MM-dd');
}

/**
* Update the LAST_API_CALL field on Sendinblue.
*/
export async function updateSendinblue(user: SupabaseUser): Promise<void> {
if (!user.sendinblue_contact_id) {
sentryException(
new Error(`User ${user.id} does not have a sendinblue_contact_id`)
);
return;
}

return sendinblueApi
.updateContact(user.sendinblue_contact_id, {
attributes: {
SUPABASE_UUID: user.id, // This should be set already, but we re-set it just in case.
LAST_API_CALL: sendinblueDateFormat(new Date()),
},
})
.then(() => {
/* do nothing */
})
.catch(sentryException);
}
Loading

0 comments on commit ef97a86

Please sign in to comment.