Skip to content

Commit

Permalink
Merge pull request #172 from makeopensource/webhooks
Browse files Browse the repository at this point in the history
Webhooks
  • Loading branch information
jessehartloff authored Nov 5, 2024
2 parents 1f33165 + 1345584 commit 5d48a67
Show file tree
Hide file tree
Showing 16 changed files with 410 additions and 1 deletion.
2 changes: 1 addition & 1 deletion devU-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"scripts": {
"start": "npm run migrate && ts-node-dev src/index.ts",
"migrate": "npm run typeorm -- migration:run -d src/database.ts",
"update-shared": "cd ../devU-shared && npm run build-local && npm i",
"update-shared": "cd ../devU-shared && npm run build-local && cd ../devU-api && npm i",
"typeorm": "typeorm-ts-node-commonjs",
"test": "jest --passWithNoTests",
"clean": "rimraf build/*",
Expand Down
71 changes: 71 additions & 0 deletions devU-api/src/entities/webhooks/webhooks.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { NextFunction, Request, Response } from 'express'

import { Updated } from '../../utils/apiResponse.utils'
import { serialize } from './webhooks.serializer'
import WebhooksService from './webhooks.service'

export async function get(req: Request, res: Response, next: NextFunction) {
try {
const hooksModel = await WebhooksService.list()
const hooks = hooksModel.map(serialize)
res.status(200).json(hooks)
} catch (err) {
next(err)
}
}

export async function getById(req: Request, res: Response, next: NextFunction) {
try {
const hooksModel = await WebhooksService.retrieveByUserId(req.currentUser!.userId!)
if (!hooksModel) {
return res.status(404).json({ 'error': 'Webhook for user not found not found' })
}
const hooks = hooksModel.map(serialize)

res.status(200).json(hooks)
} catch (err) {
next(err)
}
}


export async function post(req: Request, res: Response, next: NextFunction) {
try {
req.body['userId'] = req.currentUser!.userId!

const created = await WebhooksService.create(req.body)
res.status(201).json(serialize(created))
} catch (err: any) {
next(err)
}
}

export async function put(req: Request, res: Response, next: NextFunction) {
try {
const webhookId = parseInt(req.params.id)
await WebhooksService.update(webhookId, req.body)

res.status(201).json(Updated)
} catch (err: any) {
next(err)
}
}

export async function _delete(req: Request, res: Response, next: NextFunction) {
try {
const webhookId = parseInt(req.params.id)
await WebhooksService._delete(webhookId)

res.status(204).json('Deleted')
} catch (err: any) {
next(err)
}
}

export default {
get,
put,
post,
_delete,
getById,
}
67 changes: 67 additions & 0 deletions devU-api/src/entities/webhooks/webhooks.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { NextFunction, Request, Response } from 'express'
import WebhooksService from './webhooks.service'
import fetch from 'node-fetch'


export async function processWebhook(statusCode: number, userId: number, path: string, body: any) {
return new Promise(async (resolve, reject) => {
try {
// process path and check for match
const hooks = await WebhooksService.retrieveByUserId(userId)
if (hooks.length !== 0) {
for (let hook of hooks) {
// todo make matcher more generic that checks url patterns
if (hook.matcherUrl == path) {
try {
// todo add multiple ways to format a webhook message currently only formated for discord
const re = await fetch(hook.destinationUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ 'content': body }),
})

if (re.status >= 300) {
reject(`Failed to send webhook ${re.statusText}`)
}
} catch (e) {
reject(e)
}
}
}
// prepare request and send
} else {
reject('Url could not be matched')
}

resolve('Goodbye and thanks for all the fish')
} catch (e) {
reject(e)
}
})
}

// adds a
export function responseInterceptor(req: Request, res: Response, next: NextFunction) {
const originalSend = res.send

// Override function
// @ts-ignore
res.send = function(body) {
// send response to client
originalSend.call(this, body)

// send body for processing in webhook
if (req.currentUser?.userId) {
processWebhook(res.statusCode, req.currentUser?.userId, req.originalUrl, body).then(
value => {
console.log('Sent webhook successfully')
},
).catch(err => {
console.error('Error sending webhook', err)
})
}
}
next()
}
38 changes: 38 additions & 0 deletions devU-api/src/entities/webhooks/webhooks.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
JoinColumn,
ManyToOne,
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
} from 'typeorm'

import UserModel from '../user/user.model'

@Entity('Webhooks')
export default class WebhooksModel {
@PrimaryGeneratedColumn()
id: number

@Column({name: 'destination_url'})
destinationUrl: string

@Column({name: 'matcher_url'})
matcherUrl: string

@Column({ name: 'user_id' })
@JoinColumn({ name: 'user_id' })
@ManyToOne(() => UserModel)
userId: number

@CreateDateColumn({ name: 'created_at' })
createdAt: Date

@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date

@DeleteDateColumn({ name: 'deleted_at' })
deletedAt?: Date
}
54 changes: 54 additions & 0 deletions devU-api/src/entities/webhooks/webhooks.router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import express from 'express'

import validator from './webhooks.validator'

import WebhooksController from './webhooks.controller'
import { isAuthorized } from '../../authorization/authorization.middleware'

const Router = express.Router({ mergeParams: true })


/**
* @swagger
* /webhooks:
* get:
* summary: get all webhooks created by a user
*/
Router.get('/', isAuthorized('courseViewAll'), WebhooksController.getById)

/**
* @swagger
* /webhooks:
* get:
* summary: get all webhooks, only for admins
*/
Router.get('/all', isAuthorized('admin'), WebhooksController.get)


/**
* @swagger
* /webhooks:
* get:
* summary: create a webhook
*/
Router.post('/', isAuthorized('courseViewAll'), validator, WebhooksController.post)


/**
* @swagger
* /webhooks:
* put:
* summary: Edit webhook urls
*/
Router.put('/:id', isAuthorized('courseViewAll'), validator, WebhooksController.put)


/**
* @swagger
* /webhooks:
* delete:
* summary: delete a webhook
*/
Router.delete('/:id', isAuthorized('courseViewAll'), WebhooksController._delete)

export default Router
13 changes: 13 additions & 0 deletions devU-api/src/entities/webhooks/webhooks.serializer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import WebhooksModel from './webhooks.model'
import { Webhooks } from 'devu-shared-modules'

export function serialize(webhooks: WebhooksModel): Webhooks {
return {
id: webhooks.id,
userId: webhooks.userId,
destinationUrl: webhooks.destinationUrl,
matcherUrl: webhooks.matcherUrl,
updatedAt: webhooks.updatedAt.toISOString(),
createdAt: webhooks.createdAt.toISOString(),
}
}
36 changes: 36 additions & 0 deletions devU-api/src/entities/webhooks/webhooks.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { dataSource } from '../../database'
import WebhooksModel from './webhooks.model'
import { Webhooks } from 'devu-shared-modules'
import { IsNull } from 'typeorm'

const connect = () => dataSource.getRepository(WebhooksModel)

async function create(input: Webhooks) {
return await connect().save(input)
}



async function retrieveByUserId(userId: number) {
return await connect().findBy({ userId: userId, deletedAt: IsNull() })
}

async function update(id: number, input: Webhooks) {
return await connect().update(id, { matcherUrl: input.matcherUrl, destinationUrl: input.destinationUrl })
}

async function list() {
return await connect().findBy({ deletedAt: IsNull() })
}

async function _delete(id: number) {
return await connect().softDelete({ id, deletedAt: IsNull() })
}

export default {
create,
list,
update,
retrieveByUserId,
_delete,
}
16 changes: 16 additions & 0 deletions devU-api/src/entities/webhooks/webhooks.validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { check } from 'express-validator'

import validate from '../../middleware/validator/generic.validator'


const destinationUrl = check('destinationUrl').isString().trim()
const matcherUrl = check('matcherUrl').isString().trim()


const validator = [
destinationUrl,
matcherUrl,
validate,
]

export default validator
3 changes: 3 additions & 0 deletions devU-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import errorHandler from './middleware/errorHandler.middleware'

// Authentication Handlers
import './utils/passport.utils'
import { responseInterceptor } from './entities/webhooks/webhooks.middleware'

const app = express()

Expand All @@ -43,6 +44,8 @@ initializeMinio()

console.log(`Api: ${environment.isDocker ? '' : 'not'} running in docker`)

app.use(responseInterceptor)

// Middleware;
app.use('/', router)
app.use(errorHandler)
Expand Down
16 changes: 16 additions & 0 deletions devU-api/src/migration/1730698767895-webhooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class Webhooks1730698767895 implements MigrationInterface {
name = 'Webhooks1730698767895'

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "Webhooks" ("id" SERIAL NOT NULL, "destination_url" character varying NOT NULL, "matcher_url" character varying NOT NULL, "user_id" integer NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "deleted_at" TIMESTAMP, CONSTRAINT "PK_ef540eaf209b4e5cb871ea34910" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "Webhooks" ADD CONSTRAINT "FK_0831572f37f912eed2756aef1af" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Webhooks" DROP CONSTRAINT "FK_0831572f37f912eed2756aef1af"`);
await queryRunner.query(`DROP TABLE "Webhooks"`);
}

}
4 changes: 4 additions & 0 deletions devU-api/src/router/courseData.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import role from '../entities/role/role.router'
import nonContainerAutoGraderRouter from '../entities/nonContainerAutoGrader/nonContainerAutoGrader.router'

import { asInt } from '../middleware/validator/generic.validator'
import webhooksRouter from '../entities/webhooks/webhooks.router'

const assignmentRouter = express.Router({ mergeParams: true })
assignmentRouter.use('/assignment-problems', assignmentProblem)
Expand All @@ -41,4 +42,7 @@ Router.use('/file-upload', fileUpload)
Router.use('/roles', role)
Router.use('/user-courses', userCourse)

Router.use('/webhooks', webhooksRouter)


export default Router
5 changes: 5 additions & 0 deletions devU-client/src/components/authenticatedRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import CoursesListPage from "./pages/listPages/courses/coursesListPage";
import AssignmentProblemFormPage from './pages/forms/assignments/assignmentProblemFormPage'
import InstructorSubmissionspage from "./pages/submissions/InstructorSubmissionspage";

import WebhookURLForm from './pages/webhookURLForm'

const AuthenticatedRouter = () => (
<Switch>

Expand All @@ -43,13 +45,16 @@ const AuthenticatedRouter = () => (
component={NonContainerAutoGraderForm}/>
<Route exact path='/course/:courseId/assignment/:assignmentId/createCAG' component={ContainerAutoGraderForm}/>
<Route exact path='/course/:courseId/assignment/:assignmentId/createProblem' component={AssignmentProblemFormPage}/>
<Route exact path='/course/:courseId/webhooks' component={WebhookURLForm}/>

<Route exact path='/course/:courseId/assignment/:assignmentId/submission/:submissionId'
component={SubmissionDetailPage}/>
<Route exact path='/course/:courseId/assignment/:assignmentId/submissions'
component={InstructorSubmissionspage}/>
<Route exact path='/course/:courseId/assignment/:assignmentId/submission/:submissionId/feedback'
component={SubmissionFeedbackPage}/>
// TBD, undecided where webhooks should be placed
{/*<Route exact path='/webhookURLPage' component={webhookURLForm}/>*/}

<Route component={NotFoundPage}/>
</Switch>
Expand Down
5 changes: 5 additions & 0 deletions devU-client/src/components/pages/webhookURLForm.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@import 'variables';

.textField {
align-items: center;
}
Loading

0 comments on commit 5d48a67

Please sign in to comment.