Skip to content

Commit

Permalink
Adding the FP approach to the middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
dejanvasic85 committed Nov 12, 2023
1 parent 9864146 commit aca3226
Show file tree
Hide file tree
Showing 13 changed files with 371 additions and 90 deletions.
23 changes: 15 additions & 8 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { Handle } from '@sveltejs/kit';

import { createUser, getUserByAuthId, getAuthUserProfile } from '$lib/services/userService';
import { verifyToken } from '$lib/verifyToken';
import * as E from 'fp-ts/lib/Either';
import * as TE from 'fp-ts/lib/TaskEither';
import { pipe } from 'fp-ts/lib/function';

import { verifyToken } from '$lib/auth/verifyToken';
import { getOrCreateUserByAuth } from '$lib/services/userService';

const getTokenFromHeader = (authHeader: string) => authHeader.split(' ')[1];

Expand All @@ -14,12 +18,15 @@ export const handle: Handle = async ({ event, resolve }) => {
throw new Error('Invalid token');
}

const user = await getUserByAuthId(decodedToken.sub);
if (user) {
event.locals.user = user;
} else {
const authUserProfile = await getAuthUserProfile({ accessToken });
event.locals.user = await createUser({ authUserProfile });
const result = await pipe(
getOrCreateUserByAuth({ accessToken, authId: decodedToken.sub }),
TE.map((user) => {
event.locals.user = user;
})
)();

if (E.isLeft(result)) {
throw new Error('Failed to get or create user');
}
}

Expand Down
23 changes: 23 additions & 0 deletions src/lib/auth/fetchUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { PUBLIC_AUTH0_DOMAIN } from '$env/static/public';

import { type AuthUserProfile, AuthUserProfileSchema } from '$lib/types';

export async function fetchAuthUser({
accessToken
}: {
accessToken: string;
}): Promise<AuthUserProfile> {
const resp = await fetch(`https://${PUBLIC_AUTH0_DOMAIN}/userinfo`, {
headers: {
Authorization: `Bearer ${accessToken}`
}
});

if (resp.ok) {
const profile = await resp.json();
return AuthUserProfileSchema.parse(profile);
}

const text = await resp.text();
throw new Error(`Failed to fetch auth user. Resp: ${text}, Status: ${resp.status}`);
}
File renamed without changes.
File renamed without changes.
Empty file removed src/lib/db/types.ts
Empty file.
109 changes: 107 additions & 2 deletions src/lib/db/userDb.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { describe, it, vi, type Mocked, expect, beforeEach } from 'vitest';
import db from '$lib/db';

import { getUser } from './userDb';
import { getUser, getUserByAuthId, createUser } from './userDb';

vi.mock('$lib/db', () => ({
default: {
user: {
findUnique: vi.fn()
findUnique: vi.fn(),
create: vi.fn()
}
}
}));
Expand Down Expand Up @@ -89,3 +90,107 @@ describe('getUser', () => {
});
});
});

describe('getUserByAuthId', () => {
it('should return a user successfully', async () => {
dbUserMock.findUnique.mockResolvedValue({
id: 'hello world',
name: 'Goerge Costanza',
boards: [
{
id: 'bid_123',
notes: [
{
id: 'nid_333',
text: 'hello world'
}
]
}
]
} as any);

const result = await getUserByAuthId('auth_123')();
expect(result).toBeRightStrictEqual({
id: 'hello world',
name: 'Goerge Costanza',
boards: []
});
});

it('should return RecordNotFoundError when the user is null', async () => {
dbUserMock.findUnique.mockResolvedValue(null as any);

const result = await getUserByAuthId('auth_123')();
expect(result).toBeLeftStrictEqual({
_tag: 'RecordNotFound',
message: 'User with authId auth_123 not found'
});
});

it('should return a DatabaseError when the db throws an error', async () => {
dbUserMock.findUnique.mockRejectedValue(new Error('Something went wrong'));

const result = await getUserByAuthId('auth_123')();
expect(result).toBeLeftStrictEqual({
_tag: 'DatabaseError',
message: 'Database error',
originalError: new Error('Something went wrong')
});
});
});

describe('createUser', () => {
it('should create a user successfully', async () => {
dbUserMock.create.mockResolvedValue({
id: 'hello world',
name: 'Goerge Costanza',
boards: [
{
id: 'bid_123',
notes: [
{
id: 'nid_333',
text: 'hello world'
}
]
}
]
} as any);

const result = await createUser({
authUserProfile: {
email: 'email@foobar.com',
email_verified: true,
name: 'name',
picture: 'picture',
sub: 'sub',
nickname: 'nickname',
updated_at: 'updated_at'
}
})();

expect(result._tag).toBe('Right');
});

it('should return a DatabaseError when the db throws an error', async () => {
dbUserMock.create.mockRejectedValue(new Error('Something went wrong'));

const result = await createUser({
authUserProfile: {
email: 'email@foobar.com',
email_verified: true,
name: 'name',
picture: 'picture',
sub: 'sub',
nickname: 'nickname',
updated_at: 'updated_at'
}
})();

expect(result).toBeLeftStrictEqual({
_tag: 'DatabaseError',
message: 'Database error',
originalError: new Error('Something went wrong')
});
});
});
71 changes: 58 additions & 13 deletions src/lib/db/userDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import * as TE from 'fp-ts/TaskEither';
import { pipe } from 'fp-ts/lib/function';

import db from '$lib/db';
import type { ServerError, User } from '$lib/types';
import { fromNullableRecord, toDatabasError } from './utils';
import { generateId } from '$lib/identityGenerator';
import type { AuthUserProfile, ServerError, User } from '$lib/types';

import { fromNullableRecord, tryDbTask } from './utils';

interface GetUserByIdTaskParams {
id: string;
Expand All @@ -17,19 +19,17 @@ export const getUser = ({
includeNotes = true
}: GetUserByIdTaskParams): TE.TaskEither<ServerError, User> =>
pipe(
TE.tryCatch(
() =>
db.user.findUnique({
where: { id },
include: {
boards: {
include: {
notes: includeNotes
}
tryDbTask(() =>
db.user.findUnique({
where: { id },
include: {
boards: {
include: {
notes: includeNotes
}
}
}),
toDatabasError
}
})
),
TE.chain(fromNullableRecord(`User with id ${id} not found`)),
TE.map((user) => ({
Expand All @@ -39,3 +39,48 @@ export const getUser = ({
: user.boards.map((board) => ({ ...board, notes: includeNotes ? board.notes : [] }))
}))
);

export const getUserByAuthId = (authId: string): TE.TaskEither<ServerError, User> =>
pipe(
tryDbTask(() => db.user.findUnique({ where: { authId } })),
TE.chain(fromNullableRecord(`User with authId ${authId} not found`)),
TE.map((user) => ({
...user,
boards: []
}))
);

export const createUser = ({
authUserProfile
}: {
authUserProfile: AuthUserProfile;
}): TE.TaskEither<ServerError, User> => {
return tryDbTask(() => {
const { email, email_verified, name, picture, sub } = authUserProfile;
return db.user.create({
data: {
id: generateId('uid'),
authId: sub,
name,
email,
emailVerified: email_verified,
picture,
boards: {
create: [
{
id: generateId('bid'),
noteOrder: []
}
]
}
},
include: {
boards: {
include: {
notes: true
}
}
}
});
});
};
8 changes: 6 additions & 2 deletions src/lib/db/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as TE from 'fp-ts/TaskEither';
import type { DatabaseError, DatabaseResult, RecordNotFoundError } from './types';
import type { DatabaseError, ServerError, RecordNotFoundError } from '$lib/types';

export const tryDbTask = <T>(func: () => Promise<T>): TE.TaskEither<ServerError, T> => {
return TE.tryCatch(func, toDatabasError);
};

export const toDatabasError = (err: unknown) => {
if (err instanceof Error) {
Expand All @@ -21,7 +25,7 @@ export const toDatabasError = (err: unknown) => {

export const fromNullableRecord =
<T>(message: string) =>
(value: T | null): TE.TaskEither<DatabaseResult, T> => {
(value: T | null): TE.TaskEither<ServerError, T> => {
if (!value) {
const recordNotFoundError: RecordNotFoundError = {
_tag: 'RecordNotFound',
Expand Down
3 changes: 2 additions & 1 deletion src/lib/mapApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import type { ApiError, ServerError } from '$lib/types';

export const mapToApiError = <T extends ServerError>(err: T): ApiError => {
switch (err._tag) {
case 'DatabaseError': {
case 'DatabaseError':
case 'FetchError': {
return { status: 500, message: err.message };
}
case 'RecordNotFound': {
Expand Down
Loading

0 comments on commit aca3226

Please sign in to comment.