refactor api and sdk

This commit is contained in:
Carl-Gerhard Lindesvärd
2023-10-12 12:16:33 +02:00
parent 5b9a01c665
commit 8a2417de5a
18 changed files with 298 additions and 292 deletions

View File

@@ -17,6 +17,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"prisma": "^5.4.2", "prisma": "^5.4.2",
"random-animal-name": "^0.1.1",
"uuid": "^9.0.1" "uuid": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,5 @@
-- DropIndex
DROP INDEX "profiles_project_id_external_id_key";
-- AlterTable
ALTER TABLE "profiles" ALTER COLUMN "external_id" DROP NOT NULL;

View File

@@ -69,7 +69,7 @@ model Event {
model Profile { model Profile {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
external_id String external_id String?
first_name String? first_name String?
last_name String? last_name String?
email String? email String?
@@ -82,7 +82,6 @@ model Profile {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
@@unique([project_id, external_id])
@@map("profiles") @@map("profiles")
} }

View File

@@ -1,9 +1,10 @@
import express from 'express' import express, { ErrorRequestHandler } from 'express'
import events from './routes/events' import events from './routes/events'
import profiles from './routes/profiles' import profiles from './routes/profiles'
import { authMiddleware } from './middlewares/auth' import { authMiddleware } from './middlewares/auth'
import morgan from 'morgan' import morgan from 'morgan'
import { setup } from './routes/setup' import { setup } from './routes/setup'
import { errorHandler } from './middlewares/errors'
const app = express() const app = express()
const port = process.env.PORT || 8080 const port = process.env.PORT || 8080
@@ -21,6 +22,9 @@ if (process.env.SETUP) {
app.use(authMiddleware) app.use(authMiddleware)
app.use('/api/sdk', events) app.use('/api/sdk', events)
app.use('/api/sdk', profiles) app.use('/api/sdk', profiles)
app.use(errorHandler)
app.listen(port, () => { app.listen(port, () => {
console.log(`Listening on port ${port}...`) console.log(`Listening on port ${port}...`)
}) })

View File

@@ -1,14 +1,12 @@
import { NextFunction, Request, Response } from "express" import { NextFunction, Request, Response } from "express"
import { db } from "../db" import { db } from "../db"
import { createError } from "../responses/errors"
export async function authMiddleware(req: Request, res: Response, next: NextFunction) { export async function authMiddleware(req: Request, res: Response, next: NextFunction) {
const secret = req.headers['mixan-client-secret'] as string | undefined const secret = req.headers['mixan-client-secret'] as string | undefined
if(!secret) { if(!secret) {
return res.status(401).json({ return next(createError(401, 'Misisng client secret'))
code: 'UNAUTHORIZED',
message: 'Missing client secret',
})
} }
const client = await db.client.findFirst({ const client = await db.client.findFirst({
@@ -18,10 +16,7 @@ export async function authMiddleware(req: Request, res: Response, next: NextFunc
}) })
if(!client) { if(!client) {
return res.status(401).json({ return next(createError(401, 'Invalid client secret'))
code: 'UNAUTHORIZED',
message: 'Invalid client secret',
})
} }
req.client = { req.client = {

View File

@@ -0,0 +1,16 @@
import { NextFunction, Request, Response } from "express";
import { HttpError } from "../responses/errors";
import { MixanErrorResponse } from "@mixan/types";
export const errorHandler = (error: HttpError | Error, req: Request, res: Response, next: NextFunction) => {
if(error instanceof HttpError) {
return res.status(error.status).json(error.toJson())
}
return res.status(500).json({
code: 500,
status: 'error',
message: error.message || 'Unexpected error occured',
issues: []
} satisfies MixanErrorResponse);
};

View File

@@ -1,40 +1,38 @@
import { import {
MixanIssue, MixanIssue,
MixanErrorResponse, MixanErrorResponse
MixanIssuesResponse,
} from '@mixan/types' } from '@mixan/types'
export function issues(arr: Array<MixanIssue>): MixanIssuesResponse { export class HttpError extends Error {
return { public status: number
issues: arr.map((item) => { public message: string
return { public issues: MixanIssue[]
field: item.field,
message: item.message, constructor(status: number, message: string | Error, issues?: MixanIssue[]) {
value: item.value, super(message instanceof Error ? message.message : message)
} this.status = status
}), this.message = message instanceof Error ? message.message : message
this.issues = issues || []
}
toJson(): MixanErrorResponse {
return {
code: this.status,
status: 'error',
message: this.message,
issues: this.issues,
}
} }
} }
export function makeError(error: unknown): MixanErrorResponse { export function createIssues(arr: Array<MixanIssue>) {
if (error instanceof Error) { throw new HttpError(400, 'Issues', arr)
return { }
code: 'Error',
message: error.message, export function createError(status = 500, error: unknown | Error | string) {
} if(error instanceof Error || typeof error === 'string') {
} return new HttpError(status, error)
}
// @ts-ignore
if ('message' in error) { return new HttpError(500, 'Unexpected error occured')
return {
code: 'UnknownError',
// @ts-ignore
message: error.message,
}
}
return {
code: 'UnknownError',
message: 'Unknown error',
}
} }

View File

@@ -1,39 +1,41 @@
import {Router} from 'express' import {NextFunction, Response, Router} from 'express'
import { db } from '../db'; import { db } from '../db';
import { MixanRequest } from '../types/express'; import { MixanRequest } from '../types/express';
import { EventPayload } from '@mixan/types'; import { EventPayload } from '@mixan/types';
import { getEvents, getProfileIdFromEvents } from '../services/event'; import { getEvents } from '../services/event';
import { success } from '../responses/success'; import { success } from '../responses/success';
import { makeError } from '../responses/errors';
const router = Router(); const router = Router();
type PostRequest = MixanRequest<Array<EventPayload>> type PostRequest = MixanRequest<Array<EventPayload>>
router.get('/events', async (req, res) => { router.get('/events', async (req, res, next) => {
try { try {
const events = await getEvents(req.client.project_id) const events = await getEvents(req.client.project_id)
res.json(success(events)) res.json(success(events))
} catch (error) { } catch (error) {
res.json(makeError(error)) next(error)
} }
}) })
router.post('/events', async (req: PostRequest, res) => { router.post('/events', async (req: PostRequest, res: Response, next: NextFunction) => {
const projectId = req.client.project_id try {
const profileId = await getProfileIdFromEvents(projectId, req.body) const projectId = req.client.project_id
await db.event.createMany({
data: req.body.map((event) => ({
name: event.name,
properties: event.properties,
createdAt: event.time,
project_id: projectId,
profile_id: event.profileId,
}))
})
await db.event.createMany({ res.status(201).json(success())
data: req.body.map(event => ({ } catch (error) {
name: event.name, next(error)
properties: event.properties, }
createdAt: event.time,
project_id: projectId,
profile_id: profileId,
}))
})
res.status(201).json(success())
}) })
export default router export default router

View File

@@ -1,132 +1,131 @@
import { Router } from 'express' import { NextFunction, Response, Router } from 'express'
import { db } from '../db' import { db } from '../db'
import { MixanRequest } from '../types/express' import { MixanRequest } from '../types/express'
import { import { getProfile, tickProfileProperty } from '../services/profile'
createProfile,
getProfileByExternalId,
updateProfile,
} from '../services/profile'
import { import {
ProfileDecrementPayload, ProfileDecrementPayload,
ProfileIncrementPayload, ProfileIncrementPayload,
ProfilePayload, ProfilePayload,
} from '@mixan/types' } from '@mixan/types'
import { issues } from '../responses/errors'
import { success } from '../responses/success' import { success } from '../responses/success'
import randomAnimalName from 'random-animal-name'
const router = Router() const router = Router()
type PostRequest = MixanRequest<ProfilePayload> type PostRequest = MixanRequest<ProfilePayload>
router.get('/profiles', async (req, res) => { router.get('/profiles', async (req, res, next) => {
res.json(success(await db.profile.findMany({ try {
where: { res.json(
project_id: req.client.project_id, success(
}, await db.profile.findMany({
}))) where: {
}) project_id: req.client.project_id,
},
router.post('/profiles', async (req: PostRequest, res) => { })
const body = req.body )
const projectId = req.client.project_id )
const profile = await getProfileByExternalId(projectId, body.id) } catch (error) {
if (profile) { next(error)
await updateProfile(projectId, body.id, body, profile)
} else {
await createProfile(projectId, body)
} }
res.status(profile ? 200 : 201).json(success())
}) })
router.post( router.post(
'/profiles/increment', '/profiles',
async (req: MixanRequest<ProfileIncrementPayload>, res) => { async (
const body = req.body req: MixanRequest<{
const projectId = req.client.project_id id: string
const profile = await getProfileByExternalId(projectId, body.id) properties?: Record<string, any>
}>,
if (profile) { res: Response,
const existingProperties = ( next: NextFunction
typeof profile.properties === 'object' ? profile.properties || {} : {} ) => {
) as Record<string, number> try {
const value = const projectId = req.client.project_id
body.name in existingProperties ? existingProperties[body.name] : 0 const { id, properties } = req.body
const properties = { const profile = await db.profile.create({
...existingProperties,
[body.name]: value + body.value,
}
if (typeof value !== 'number') {
return res.status(400).json(
issues([
{
field: 'name',
message: 'Property is not a number',
value,
},
])
)
}
await db.profile.updateMany({
where: {
external_id: String(body.id),
project_id: req.client.project_id,
},
data: { data: {
properties, id,
external_id: null,
email: null,
first_name: randomAnimalName(),
last_name: null,
avatar: null,
properties: {
...(properties || {}),
},
project_id: projectId,
}, },
}) })
}
res.status(200).json(success()) res.status(201).json(success(profile))
} catch (error) {
next(error)
}
} }
) )
router.post( router.put('/profiles/:id', async (req: PostRequest, res: Response, next: NextFunction) => {
'/profiles/decrement', try {
async (req: MixanRequest<ProfileDecrementPayload>, res) => { const profileId = req.params.id
const body = req.body const profile = await getProfile(profileId)
const projectId = req.client.project_id const { body } = req
const profile = await getProfileByExternalId(projectId, body.id)
if (profile) { if (profile) {
const existingProperties = ( await db.profile.update({
typeof profile.properties === 'object' ? profile.properties || {} : {}
) as Record<string, number>
const value =
body.name in existingProperties ? existingProperties[body.name] : 0
if (typeof value !== 'number') {
return res.status(400).json(
issues([
{
field: 'name',
message: 'Property is not a number',
value,
},
])
)
}
const properties = {
...existingProperties,
[body.name]: value - body.value,
}
await db.profile.updateMany({
where: { where: {
external_id: String(body.id), id: profileId,
project_id: req.client.project_id,
}, },
data: { data: {
properties, external_id: body.id,
email: body.email,
first_name: body.first_name,
last_name: body.last_name,
avatar: body.avatar,
properties: {
...(typeof profile.properties === 'object'
? profile.properties || {}
: {}),
...(body.properties || {}),
},
}, },
}) })
}
res.status(200).json(success()) res.status(200).json(success())
}
} catch (error) {
next(error)
}
})
router.put(
'/profiles/:id/increment',
async (req: MixanRequest<ProfileIncrementPayload>, res: Response, next: NextFunction) => {
try {
await tickProfileProperty({
name: req.body.name,
tick: req.body.value,
profileId: req.params.id,
})
res.status(200).json(success())
} catch (error) {
next(error)
}
}
)
router.put(
'/profiles/:id/decrement',
async (req: MixanRequest<ProfileDecrementPayload>, res: Response, next: NextFunction) => {
try {
await tickProfileProperty({
name: req.body.name,
tick: -Math.abs(req.body.value),
profileId: req.params.id,
})
res.status(200).json(success())
} catch (error) {
next(error)
}
} }
) )

View File

@@ -1,9 +1,8 @@
import { Request, Response } from 'express' import { NextFunction, Request, Response } from 'express'
import { db } from '../db' import { db } from '../db'
import { makeError } from '../responses/errors'
import { v4 as uuid } from 'uuid' import { v4 as uuid } from 'uuid'
export async function setup(req: Request, res: Response) { export async function setup(req: Request, res: Response, next: NextFunction) {
try { try {
const organization = await db.organization.create({ const organization = await db.organization.create({
data: { data: {
@@ -22,7 +21,7 @@ export async function setup(req: Request, res: Response) {
data: { data: {
name: 'Acme Website Client', name: 'Acme Website Client',
project_id: project.id, project_id: project.id,
secret: uuid(), secret: '4bfc4a0b-37e0-4916-b634-95c6a32a2e77',
}, },
}) })
@@ -32,6 +31,6 @@ export async function setup(req: Request, res: Response) {
client, client,
}) })
} catch (error) { } catch (error) {
res.json(makeError(error)) next(error)
} }
} }

View File

@@ -1,4 +1,3 @@
import { EventPayload } from "@mixan/types";
import { db } from "../db"; import { db } from "../db";
export function getEvents(projectId: string) { export function getEvents(projectId: string) {
@@ -8,22 +7,3 @@ export function getEvents(projectId: string) {
} }
}) })
} }
export async function getProfileIdFromEvents(projectId: string, events: EventPayload[]) {
const event = events.find(item => !!item.externalId)
if(event?.externalId) {
return db.profile.findUnique({
where: {
project_id_external_id: {
project_id: projectId,
external_id: event.externalId,
}
}
}).then((res) => {
return res?.id || null
}).catch(() => {
return null
})
}
return null
}

View File

@@ -1,7 +0,0 @@
export async function hashPassword(password: string) {
return await Bun.password.hash(password);
}
export async function verifyPassword(password: string,hashedPassword: string) {
return await Bun.password.verify(password, hashedPassword);
}

View File

@@ -1,32 +1,15 @@
import { EventPayload, ProfilePayload } from "@mixan/types"; import { EventPayload, ProfilePayload } from '@mixan/types'
import { db } from "../db"; import { db } from '../db'
import { Prisma } from "@prisma/client"; import { Prisma } from '@prisma/client'
import { HttpError } from '../responses/errors'
export function createProfile(projectId: string, payload: ProfilePayload) { type DbProfile = Exclude<Prisma.PromiseReturnType<typeof getProfile>, null>
const { id, email, first_name, last_name, avatar, properties } = payload
return db.profile.create({
data:{
external_id: id,
email,
first_name,
last_name,
avatar,
properties: properties || {},
project_id: projectId,
}
})
}
type DbProfile = Exclude<Prisma.PromiseReturnType<typeof getProfileByExternalId>, null> export function getProfile(id: string) {
return db.profile.findUniqueOrThrow({
export function getProfileByExternalId(projectId: string, externalId: string) {
return db.profile.findUnique({
where: { where: {
project_id_external_id: { id,
project_id: projectId, },
external_id: externalId,
}
}
}) })
} }
@@ -34,42 +17,47 @@ export function getProfiles(projectId: string) {
return db.profile.findMany({ return db.profile.findMany({
where: { where: {
project_id: projectId, project_id: projectId,
} },
}) })
} }
export async function updateProfile(projectId: string, profileId: string, payload: Omit<ProfilePayload, 'id'>, oldProfile: DbProfile) { export async function tickProfileProperty({
const { email, first_name, last_name, avatar, properties } = payload profileId,
return db.profile.update({ tick,
name,
}: {
profileId: string
tick: number
name: string
}) {
const profile = await getProfile(profileId)
if (!profile) {
throw new HttpError(404, `Profile not found ${profileId}`)
}
const properties = (
typeof profile.properties === 'object' ? profile.properties || {} : {}
) as Record<string, number>
const value = name in properties ? properties[name] : 0
if (typeof value !== 'number') {
throw new HttpError(400, `Property "${name}" on user is of type ${typeof value}`)
}
if (typeof tick !== 'number') {
throw new HttpError(400, `Value is not a number ${tick} (${typeof tick})`)
}
await db.profile.update({
where: { where: {
project_id_external_id: { id: profileId,
project_id: projectId,
external_id: profileId,
}
}, },
data: { data: {
email,
first_name,
last_name,
avatar,
properties: { properties: {
...(typeof oldProfile.properties === 'object' ? oldProfile.properties || {} : {}), ...properties,
...(properties || {}), [name]: value + tick,
}, },
}, },
}) })
} }
export async function getInternalProfileId(profileId?: string | null) {
if(!profileId) {
return null
}
const profile = await db.profile.findFirst({
where: {
external_id: profileId,
}
})
return profile?.id || null
}

View File

@@ -1,3 +1,5 @@
export type MixanRequest<Body> = Omit<Express.Request,'body'> & { import { Request } from "express"
export type MixanRequest<Body> = Omit<Request,'body'> & {
body: Body body: Body
} }

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,7 +1,7 @@
import { v4 as uuid } from 'uuid'
import { import {
EventPayload, EventPayload,
MixanErrorResponse, MixanErrorResponse,
MixanIssuesResponse,
MixanResponse, MixanResponse,
ProfilePayload, ProfilePayload,
} from '@mixan/types' } from '@mixan/types'
@@ -12,6 +12,9 @@ type MixanOptions = {
batchInterval?: number batchInterval?: number
maxBatchSize?: number maxBatchSize?: number
verbose?: boolean verbose?: boolean
saveProfileId: (profileId: string) => void,
getProfileId: () => string | null,
removeProfileId: () => void,
} }
class Fetcher { class Fetcher {
@@ -25,7 +28,11 @@ class Fetcher {
this.logger = options.verbose ? console.log : () => {} this.logger = options.verbose ? console.log : () => {}
} }
post(path: string, data: Record<string, any>) { post(
path: string,
data: Record<string, any>,
options: FetchRequestInit = {}
) {
const url = `${this.url}${path}` const url = `${this.url}${path}`
this.logger(`Mixan request: ${url}`, JSON.stringify(data, null, 2)) this.logger(`Mixan request: ${url}`, JSON.stringify(data, null, 2))
return fetch(url, { return fetch(url, {
@@ -35,30 +42,19 @@ class Fetcher {
}, },
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
...options,
}) })
.then(async (res) => { .then(async (res) => {
const response = await res.json< const response = await res.json<
MixanIssuesResponse | MixanErrorResponse | MixanResponse<unknown> MixanErrorResponse | MixanResponse<unknown>
>() >()
if ('status' in response && response.status === 'ok') {
return response
}
if ('code' in response) { if('status' in response && response.status === 'error') {
this.logger(`Mixan error: [${response.code}] ${response.message}`) this.logger(`Mixan request failed: ${url}`, JSON.stringify(response, null, 2))
return null return null
} }
if ('issues' in response) { return response
this.logger(`Mixan issues:`)
response.issues.forEach((issue) => {
this.logger(` - ${issue.message} (${issue.value})`)
})
return null
}
return null
}) })
.catch(() => { .catch(() => {
return null return null
@@ -99,7 +95,7 @@ class Batcher<T extends any> {
return return
} }
if (this.queue.length > this.maxBatchSize) { if (this.queue.length >= this.maxBatchSize) {
this.send() this.send()
return return
} }
@@ -116,16 +112,21 @@ class Batcher<T extends any> {
export class Mixan { export class Mixan {
private fetch: Fetcher private fetch: Fetcher
private eventBatcher: Batcher<EventPayload> private eventBatcher: Batcher<EventPayload>
private profile: ProfilePayload | null = null private profileId?: string
private options: MixanOptions
private logger: (...args: any[]) => void
constructor(options: MixanOptions) { constructor(options: MixanOptions) {
this.logger = options.verbose ? console.log : () => {}
this.options = options
this.fetch = new Fetcher(options) this.fetch = new Fetcher(options)
this.setAnonymousUser()
this.eventBatcher = new Batcher(options, (queue) => { this.eventBatcher = new Batcher(options, (queue) => {
this.fetch.post( this.fetch.post(
'/events', '/events',
queue.map((item) => ({ queue.map((item) => ({
...item, ...item,
externalId: item.externalId || this.profile?.id, profileId: item.profileId || this.profileId || null,
})) }))
) )
}) })
@@ -140,18 +141,34 @@ export class Mixan {
name, name,
properties, properties,
time: this.timestamp(), time: this.timestamp(),
externalId: this.profile?.id || null, profileId: this.profileId || null,
}) })
} }
private setAnonymousUser() {
const profileId = this.options.getProfileId()
if(profileId) {
this.profileId = profileId
this.logger('Use existing ID', this.profileId);
} else {
this.profileId = uuid()
this.logger('Create new ID', this.profileId);
this.options.saveProfileId(this.profileId)
this.fetch.post('/profiles', {
id: this.profileId,
properties: {},
})
}
}
async setUser(profile: ProfilePayload) { async setUser(profile: ProfilePayload) {
this.profile = profile await this.fetch.post(`/profiles/${this.profileId}`, profile, {
await this.fetch.post('/profiles', profile) method: 'PUT'
})
} }
async setUserProperty(name: string, value: any) { async setUserProperty(name: string, value: any) {
await this.fetch.post('/profiles', { await this.fetch.post(`/profiles/${this.profileId}`, {
...this.profile,
properties: { properties: {
[name]: value, [name]: value,
}, },
@@ -159,33 +176,41 @@ export class Mixan {
} }
async increment(name: string, value: number = 1) { async increment(name: string, value: number = 1) {
if (!this.profile) { if (!this.profileId) {
return return
} }
await this.fetch.post('/profiles/increment', { await this.fetch.post(`/profiles/${this.profileId}/increment`, {
id: this.profile.id,
name, name,
value, value,
}, {
method: 'PUT'
}) })
} }
async decrement(name: string, value: number = 1) { async decrement(name: string, value: number = 1) {
if (!this.profile) { if (!this.profileId) {
return return
} }
await this.fetch.post('/profiles/decrement', { await this.fetch.post(`/profiles/${this.profileId}/decrement`, {
id: this.profile.id,
name, name,
value, value,
}, {
method: 'PUT'
}) })
} }
screenView(route: string, properties?: Record<string, any>) { async screenView(route: string, properties?: Record<string, any>) {
this.event('screen_view', { await this.event('screen_view', {
...(properties || {}), ...(properties || {}),
route, route,
}) })
} }
clear() {
this.eventBatcher.flush()
this.options.removeProfileId()
this.profileId = undefined
}
} }

View File

@@ -4,9 +4,11 @@
"type": "module", "type": "module",
"module": "index.ts", "module": "index.ts",
"dependencies": { "dependencies": {
"@mixan/types": "workspace:*" "@mixan/types": "workspace:*",
"uuid": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/uuid": "^9.0.5",
"bun-types": "latest", "bun-types": "latest",
"typescript": "^5.0.0" "typescript": "^5.0.0"
} }

View File

@@ -3,7 +3,7 @@ export type MixanJson = Record<string, any>
export type EventPayload = { export type EventPayload = {
name: string name: string
time: string time: string
externalId: string | null profileId: string | null
properties: MixanJson properties: MixanJson
} }
@@ -12,8 +12,8 @@ export type ProfilePayload = {
last_name?: string last_name?: string
email?: string email?: string
avatar?: string avatar?: string
id: string id?: string
properties: MixanJson properties?: MixanJson
} }
export type ProfileIncrementPayload = { export type ProfileIncrementPayload = {
@@ -59,13 +59,11 @@ export type MixanIssue = {
value: any value: any
} }
export type MixanIssuesResponse = {
issues: Array<MixanIssue>,
}
export type MixanErrorResponse = { export type MixanErrorResponse = {
code: string status: 'error'
code: number
message: string message: string
issues: Array<MixanIssue>
} }
export type MixanResponse<T> = { export type MixanResponse<T> = {