Organize directories
This commit is contained in:
parent
8a91c9b89b
commit
4898382f78
433 changed files with 0 additions and 0 deletions
295
packages/metamigo-common/controllers/crud-controller.ts
Normal file
295
packages/metamigo-common/controllers/crud-controller.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
/* eslint-disable @typescript-eslint/ban-types,@typescript-eslint/no-explicit-any,max-params */
|
||||
import * as Boom from "@hapi/boom";
|
||||
import * as Hapi from "@hapi/hapi";
|
||||
import { CrudRepository } from "../records/crud-repository";
|
||||
import { createResponse } from "../helpers/response";
|
||||
import {
|
||||
PgRecordInfo,
|
||||
UnsavedR,
|
||||
SavedR,
|
||||
KeyType,
|
||||
} from "../records/record-info";
|
||||
|
||||
/**
|
||||
*
|
||||
* A generic controller that handles exposes a [[CrudRepository]] as HTTP
|
||||
* endpoints with full POST, PUT, GET, DELETE semantics.
|
||||
*
|
||||
* The controller yanks the instance of the crud repository out of the request at runtime.
|
||||
* This assumes you're following the pattern exposed with the hapi-pg-promise plugin.
|
||||
*
|
||||
* @typeParam ID The type of the id column
|
||||
* @typeParam T The type of the record
|
||||
*/
|
||||
export abstract class AbstractCrudController<
|
||||
TUnsavedR,
|
||||
TSavedR extends TUnsavedR & IdKeyT,
|
||||
IdKeyT extends object
|
||||
> {
|
||||
/**
|
||||
* @param repoName the key at which the repository for the record can be accessed (that is, request.db[repoName])
|
||||
* @param paramsIdField the placeholder used in the Hapi route for the id of the record
|
||||
* @param dbDecoration the decorated function on the request to use (defaults to request.db())
|
||||
*/
|
||||
|
||||
abstract repoName: string;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
abstract paramsIdField = "id";
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
abstract dbDecoration = "db";
|
||||
abstract recordType: PgRecordInfo<TUnsavedR, TSavedR, IdKeyT>;
|
||||
|
||||
repo(request: Hapi.Request): CrudRepository<TUnsavedR, TSavedR, IdKeyT> {
|
||||
// @ts-expect-error
|
||||
const db = request[this.dbDecoration];
|
||||
if (!db)
|
||||
throw Boom.badImplementation(
|
||||
`CrudController for table ${this.recordType.tableName} could not find request decoration '${this.dbDecoration}'`
|
||||
);
|
||||
const repo = db()[this.repoName];
|
||||
if (!repo)
|
||||
throw Boom.badImplementation(
|
||||
`CrudController for table ${this.recordType.tableName} could not find repository for '${this.dbDecoration}().${this.repoName}'`
|
||||
);
|
||||
return repo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new record
|
||||
*/
|
||||
public create = async (
|
||||
request: Hapi.Request,
|
||||
toolkit: Hapi.ResponseToolkit
|
||||
): Promise<any> => {
|
||||
try {
|
||||
// would love to know how to get rid of this double cast hack
|
||||
const payload: TSavedR = <TSavedR>(<any>request.payload);
|
||||
const data: TSavedR = await this.repo(request).insert(payload);
|
||||
|
||||
return toolkit.response(
|
||||
createResponse(request, {
|
||||
value: data,
|
||||
})
|
||||
);
|
||||
} catch (error: any) {
|
||||
return toolkit.response(
|
||||
createResponse(request, {
|
||||
boom: Boom.badImplementation(error),
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a record by ID. This method can accept partial updates.
|
||||
*/
|
||||
public updateById = async (
|
||||
request: Hapi.Request,
|
||||
toolkit: Hapi.ResponseToolkit
|
||||
): Promise<any> => {
|
||||
try {
|
||||
const payload: Partial<TSavedR> = <any>request.payload;
|
||||
const id: IdKeyT = request.params[this.paramsIdField];
|
||||
const updatedRow: TSavedR = await this.repo(request).updateById(
|
||||
id,
|
||||
payload
|
||||
);
|
||||
|
||||
if (!updatedRow) {
|
||||
return toolkit.response(
|
||||
createResponse(request, {
|
||||
boom: Boom.notFound(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return toolkit.response(
|
||||
createResponse(request, {
|
||||
value: updatedRow,
|
||||
})
|
||||
);
|
||||
} catch (error: any) {
|
||||
return toolkit.response(
|
||||
createResponse(request, {
|
||||
boom: Boom.badImplementation(error),
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Return a record given its id.
|
||||
*/
|
||||
public getById = async (
|
||||
request: Hapi.Request,
|
||||
toolkit: Hapi.ResponseToolkit
|
||||
): Promise<any> => {
|
||||
try {
|
||||
const id: IdKeyT = request.params[this.paramsIdField];
|
||||
// @ts-expect-error
|
||||
const row: TSavedR = await this.repo(request).findById(id);
|
||||
|
||||
if (!row) {
|
||||
return toolkit.response(
|
||||
createResponse(request, {
|
||||
boom: Boom.notFound(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return toolkit.response(
|
||||
createResponse(request, {
|
||||
value: row,
|
||||
})
|
||||
);
|
||||
} catch (error: any) {
|
||||
return toolkit.response(
|
||||
createResponse(request, {
|
||||
boom: Boom.badImplementation(error),
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Return all records.
|
||||
*/
|
||||
public getAll = async (
|
||||
request: Hapi.Request,
|
||||
toolkit: Hapi.ResponseToolkit
|
||||
): Promise<any> => {
|
||||
try {
|
||||
const rows: TSavedR[] = await this.repo(request).findAll();
|
||||
|
||||
return toolkit.response(
|
||||
createResponse(request, {
|
||||
value: rows,
|
||||
})
|
||||
);
|
||||
} catch (error: any) {
|
||||
return toolkit.response(
|
||||
createResponse(request, {
|
||||
boom: Boom.badImplementation(error),
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a record given its id.
|
||||
*/
|
||||
public deleteById = async (
|
||||
request: Hapi.Request,
|
||||
toolkit: Hapi.ResponseToolkit
|
||||
): Promise<any> => {
|
||||
try {
|
||||
const id: IdKeyT = request.params[this.paramsIdField];
|
||||
|
||||
const count = await this.repo(request).removeById(id);
|
||||
|
||||
if (count === 0) {
|
||||
return createResponse(request, { boom: Boom.notFound() });
|
||||
}
|
||||
|
||||
return toolkit.response(
|
||||
createResponse(request, {
|
||||
value: { id },
|
||||
})
|
||||
);
|
||||
} catch (error: any) {
|
||||
return toolkit.response(
|
||||
createResponse(request, {
|
||||
boom: Boom.badImplementation(error),
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
export function unboundCrudController<TRecordInfo extends PgRecordInfo>(
|
||||
aRecordType: TRecordInfo
|
||||
) {
|
||||
return class CrudController extends AbstractCrudController<
|
||||
UnsavedR<TRecordInfo>,
|
||||
SavedR<TRecordInfo>,
|
||||
KeyType<TRecordInfo>
|
||||
> {
|
||||
public readonly repoName: string;
|
||||
public readonly paramsIdField;
|
||||
public readonly dbDecoration;
|
||||
public readonly recordType = aRecordType;
|
||||
|
||||
constructor(repoName: string, paramsIdField = "id", dbDecoration = "db") {
|
||||
super();
|
||||
this.repoName = repoName;
|
||||
this.paramsIdField = paramsIdField;
|
||||
this.dbDecoration = dbDecoration;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
export function CrudControllerBase<Rec extends PgRecordInfo>(recordType: Rec) {
|
||||
return unboundCrudController<Rec>(recordType);
|
||||
}
|
||||
|
||||
export const crudRoutesFor = (
|
||||
name: string,
|
||||
path: string,
|
||||
controller: AbstractCrudController<any, any, any>,
|
||||
idParam: string,
|
||||
validate: Record<string, Hapi.RouteOptionsValidate>
|
||||
): Hapi.ServerRoute[] => [
|
||||
{
|
||||
method: "POST",
|
||||
path: `${path}`,
|
||||
options: {
|
||||
handler: controller.create,
|
||||
validate: validate.create,
|
||||
description: `Method that creates a new ${name}.`,
|
||||
tags: ["api", name],
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "PUT",
|
||||
path: `${path}/{${idParam}}`,
|
||||
options: {
|
||||
handler: controller.updateById,
|
||||
validate: validate.updateById,
|
||||
description: `Method that updates a ${name} by its id.`,
|
||||
tags: ["api", name],
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: `${path}/{${idParam}}`,
|
||||
options: {
|
||||
handler: controller.getById,
|
||||
validate: validate.getById,
|
||||
description: `Method that gets a ${name} by its id.`,
|
||||
tags: ["api", name],
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: `${path}`,
|
||||
options: {
|
||||
handler: controller.getAll,
|
||||
description: `Method that gets all ${name}s.`,
|
||||
tags: ["api", name],
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `${path}/{${idParam}}`,
|
||||
options: {
|
||||
handler: controller.deleteById,
|
||||
validate: validate.deleteById,
|
||||
description: `Method that deletes a ${name} by its id.`,
|
||||
tags: ["api", name],
|
||||
},
|
||||
},
|
||||
];
|
||||
185
packages/metamigo-common/controllers/nextauth-adapter.ts
Normal file
185
packages/metamigo-common/controllers/nextauth-adapter.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
/* eslint-disable unicorn/no-null,max-params */
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
import type { AdapterInstance } from "next-auth/adapters";
|
||||
import omit from "lodash/omit";
|
||||
import type { IMetamigoRepositories } from "../records";
|
||||
import type { UnsavedAccount, SavedAccount } from "../records/account";
|
||||
import type { UserId, UnsavedUser, SavedUser } from "../records/user";
|
||||
import type { UnsavedSession, SavedSession } from "../records/session";
|
||||
|
||||
// Sessions expire after 30 days of being idle
|
||||
export const defaultSessionMaxAge = 30 * 24 * 60 * 60 * 1000;
|
||||
// Sessions updated only if session is greater than this value (0 = always)
|
||||
export const defaulteSessionUpdateAge = 24 * 60 * 60 * 1000;
|
||||
|
||||
const getCompoundId = (providerId: any, providerAccountId: any) =>
|
||||
createHash("sha256")
|
||||
.update(`${providerId}:${providerAccountId}`)
|
||||
.digest("hex");
|
||||
|
||||
const randomToken = () => randomBytes(32).toString("hex");
|
||||
|
||||
export class NextAuthAdapter<TRepositories extends IMetamigoRepositories>
|
||||
implements AdapterInstance<SavedUser, UnsavedUser, SavedSession>
|
||||
{
|
||||
constructor(
|
||||
private repos: TRepositories,
|
||||
private readonly sessionMaxAge = defaultSessionMaxAge,
|
||||
private readonly sessionUpdateAge = defaulteSessionUpdateAge
|
||||
) { }
|
||||
|
||||
async createUser(profile: UnsavedUser): Promise<SavedUser> {
|
||||
// @ts-expect-error
|
||||
return this.repos.users.upsert(omit(profile, ["isActive", "id"]));
|
||||
}
|
||||
|
||||
async getUser(id: UserId): Promise<SavedUser | null> {
|
||||
const user = await this.repos.users.findById({ id });
|
||||
if (!user) return null;
|
||||
// if a user has no linked accounts, then we do not return it
|
||||
// see: https://github.com/nextauthjs/next-auth/issues/876
|
||||
const accounts = await this.repos.accounts.findAllBy({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (!accounts || accounts.length === 0) return null;
|
||||
return user;
|
||||
}
|
||||
|
||||
async getUserByEmail(email: string): Promise<SavedUser | null> {
|
||||
const user = await this.repos.users.findBy({ email });
|
||||
if (!user) return null;
|
||||
// if a user has no linked accounts, then we do not return it
|
||||
// see: https://github.com/nextauthjs/next-auth/issues/876
|
||||
const accounts = await this.repos.accounts.findAllBy({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (!accounts || accounts.length === 0) return null;
|
||||
return user;
|
||||
}
|
||||
|
||||
async getUserByProviderAccountId(
|
||||
providerId: string,
|
||||
providerAccountId: string
|
||||
): Promise<SavedUser | null> {
|
||||
const account = await this.repos.accounts.findBy({
|
||||
compoundId: getCompoundId(providerId, providerAccountId),
|
||||
});
|
||||
if (!account) return null;
|
||||
|
||||
return this.repos.users.findById({ id: account.userId });
|
||||
}
|
||||
|
||||
async updateUser(user: SavedUser): Promise<SavedUser> {
|
||||
return this.repos.users.update(user);
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
async linkAccount(
|
||||
userId: string,
|
||||
providerId: string,
|
||||
providerType: string,
|
||||
providerAccountId: string,
|
||||
refreshToken: string,
|
||||
accessToken: string,
|
||||
accessTokenExpires: number
|
||||
): Promise<void> {
|
||||
const exists = await this.repos.users.existsById({ id: userId });
|
||||
if (!exists) return;
|
||||
const account: UnsavedAccount = {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
compoundId: getCompoundId(providerId, providerAccountId),
|
||||
providerAccountId,
|
||||
providerId,
|
||||
providerType,
|
||||
accessTokenExpires: accessTokenExpires
|
||||
? new Date(accessTokenExpires)
|
||||
: new Date(),
|
||||
userId,
|
||||
};
|
||||
await this.repos.accounts.insert(account);
|
||||
}
|
||||
|
||||
async unlinkAccount(
|
||||
userId: string,
|
||||
providerId: string,
|
||||
providerAccountId: string
|
||||
): Promise<void> {
|
||||
await this.repos.accounts.removeBy({
|
||||
userId,
|
||||
compoundId: getCompoundId(providerId, providerAccountId),
|
||||
});
|
||||
}
|
||||
|
||||
createSession(user: SavedUser): Promise<SavedSession> {
|
||||
let expires;
|
||||
if (this.sessionMaxAge) {
|
||||
const dateExpires = new Date(Date.now() + this.sessionMaxAge);
|
||||
expires = dateExpires.toISOString();
|
||||
}
|
||||
|
||||
const session: UnsavedSession = {
|
||||
// @ts-expect-error
|
||||
expires,
|
||||
userId: user.id,
|
||||
sessionToken: randomToken(),
|
||||
accessToken: randomToken(),
|
||||
};
|
||||
|
||||
return this.repos.sessions.insert(session);
|
||||
}
|
||||
|
||||
async getSession(sessionToken: string): Promise<SavedSession | null> {
|
||||
const session = await this.repos.sessions.findBy({ sessionToken });
|
||||
if (session && session.expires && new Date() > session.expires) {
|
||||
this.repos.sessions.remove(session);
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async updateSession(
|
||||
session: SavedSession,
|
||||
force?: boolean
|
||||
): Promise<SavedSession | null> {
|
||||
if (
|
||||
this.sessionMaxAge &&
|
||||
(this.sessionUpdateAge || this.sessionUpdateAge === 0) &&
|
||||
session.expires
|
||||
) {
|
||||
// Calculate last updated date, to throttle write updates to database
|
||||
// Formula: ({expiry date} - sessionMaxAge) + sessionUpdateAge
|
||||
// e.g. ({expiry date} - 30 days) + 1 hour
|
||||
//
|
||||
// Default for sessionMaxAge is 30 days.
|
||||
// Default for sessionUpdateAge is 1 hour.
|
||||
const dateSessionIsDueToBeUpdated = new Date(
|
||||
session.expires.getTime() - this.sessionMaxAge + this.sessionUpdateAge
|
||||
);
|
||||
|
||||
// Trigger update of session expiry date and write to database, only
|
||||
// if the session was last updated more than {sessionUpdateAge} ago
|
||||
if (new Date() > dateSessionIsDueToBeUpdated) {
|
||||
const newExpiryDate = new Date();
|
||||
newExpiryDate.setTime(newExpiryDate.getTime() + this.sessionMaxAge);
|
||||
session.expires = newExpiryDate;
|
||||
} else if (!force) {
|
||||
return null;
|
||||
}
|
||||
} else if (!force) {
|
||||
// If session MaxAge, session UpdateAge or session.expires are
|
||||
// missing then don't even try to save changes, unless force is set.
|
||||
return null;
|
||||
}
|
||||
|
||||
const { expires } = session;
|
||||
return this.repos.sessions.update({ ...session, expires });
|
||||
}
|
||||
|
||||
async deleteSession(sessionToken: string): Promise<void> {
|
||||
await this.repos.sessions.removeBy({ sessionToken });
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue