Skip to content

Commit

Permalink
Replace all errors with custom error types (#839)
Browse files Browse the repository at this point in the history
Co-authored-by: Henrik Skog <henrikskog01@gmail.com>
  • Loading branch information
junlarsen and henrikskog authored Mar 20, 2024
1 parent 8b90cae commit 852260d
Show file tree
Hide file tree
Showing 43 changed files with 563 additions and 134 deletions.
49 changes: 49 additions & 0 deletions packages/core/src/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { PROBLEM_DETAILS } from "./http-problem-details"

/**
* Represents a problem details type.
*
* This implementation does not yet support:
* - instance member
* - extension members
*
* @see https://datatracker.ietf.org/doc/html/rfc9457#name-the-problem-details-json-ob
*/
export type ProblemDetail = Pick<ApplicationError, "type" | "status" | "title">

/**
* Exception type modelled after RFC9457 Problem Details for HTTP APIs
*
* @see https://tools.ietf.org/html/rfc7807
*
* This implementation does not yet support:
* - instance member
* - extension members
*
* All exceptions thrown by the application modules should be of this type in
* order to be detailed enough for the client to understand the problem and
* to be able to handle it properly.
*/
export class ApplicationError extends Error {
public readonly type: string
public readonly status: number
public readonly title: string
public readonly detail?: string

constructor(problemType: ProblemDetail, detail?: string) {
const { type, status, title } = problemType

super(detail ?? title)

this.type = type
this.status = status
this.title = title
this.detail = detail
}
}

export class IllegalStateError extends ApplicationError {
constructor(detail: string) {
super(PROBLEM_DETAILS.IllegalState, detail)
}
}
1 change: 0 additions & 1 deletion packages/core/src/errors/errors.ts

This file was deleted.

47 changes: 47 additions & 0 deletions packages/core/src/http-problem-details.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { ProblemDetail } from "./error"

const RFC_REGISTRY_BASE = "https://datatracker.ietf.org/doc/html/rfc9110#name"

type ProblemDetails = {
BadRequest: ProblemDetail
NotFound: ProblemDetail
UnprocessableContent: ProblemDetail
InternalServerError: ProblemDetail
IllegalState: ProblemDetail
}

/**
* All the supported problem details types for the application, in accordance with RFC 9457.
* If problem type is a locator, the client can dereference it to get more information about the problem.
*
* When adding new problem details types,
* @see https://datatracker.ietf.org/doc/html/rfc9457#name-defining-new-problem-types
*
*/
export const PROBLEM_DETAILS: ProblemDetails = {
BadRequest: {
status: 400,
title: "Bad Request",
type: `${RFC_REGISTRY_BASE}-400-bad-request`,
},
NotFound: {
status: 404,
title: "Not Found",
type: `${RFC_REGISTRY_BASE}-404-not-found`,
},
UnprocessableContent: {
status: 422,
title: "Unprocessable Content",
type: `${RFC_REGISTRY_BASE}-422-unprocessable-content`,
},
InternalServerError: {
status: 500,
title: "Internal Server Error",
type: `${RFC_REGISTRY_BASE}-500-internal-server-error`,
},
IllegalState: {
status: 500,
title: "Invalid state reached",
type: "/problem/illegal-state", // TODO: create page for this that describes how to resolve the problem and change this to a URL to that page. (https://datatracker.ietf.org/doc/html/rfc9457#section-4-10)
},
}
15 changes: 14 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
export * from "./errors/errors"
export * from "./error"
export * from "./utils/db-utils"
export * from "./modules/core"

export * from "./modules/article/article-error"
export * from "./modules/committee/committee-error"
export * from "./modules/company/company-error"
export * from "./modules/event/attendee-error"
export * from "./modules/event/event-error"
export * from "./modules/job-listing/job-listing-error"
export * from "./modules/offline/offline-error"
export * from "./modules/mark/mark-error"
export * from "./modules/mark/personal-mark-error"
export * from "./modules/payment/payment-error"
export * from "./modules/payment/refund-request-error"
export * from "./modules/payment/product-error"
8 changes: 8 additions & 0 deletions packages/core/src/modules/article/article-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ApplicationError } from "../../error"
import { PROBLEM_DETAILS } from "../../http-problem-details"

export class ArticleNotFoundError extends ApplicationError {
constructor(id: string) {
super(PROBLEM_DETAILS.NotFound, `Article with ID:${id} not found`)
}
}
23 changes: 19 additions & 4 deletions packages/core/src/modules/article/article-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { type ArticleRepository } from "./article-repository"
import { type ArticleTagRepository } from "./article-tag-repository"
import { type ArticleTagLinkRepository } from "./article-tag-link-repository"
import { type Cursor } from "../../utils/db-utils"
import { NotFoundError } from "../../errors/errors"
import { ArticleNotFoundError } from "./article-error"

export interface ArticleService {
create(input: ArticleWrite): Promise<Article>
Expand All @@ -35,10 +35,15 @@ export class ArticleServiceImpl implements ArticleService {
return await this.articleRepository.create(input)
}

/**
* Update an article by its id
*
* @throws {ArticleNotFoundError} if the article does not exist
*/
async update(id: ArticleId, input: Partial<ArticleWrite>): Promise<Article> {
const match = await this.articleRepository.getById(id)
if (match === undefined) {
throw new NotFoundError(`Article with ID:${id} not found`)
throw new ArticleNotFoundError(id)
}
return await this.articleRepository.update(match.id, input)
}
Expand All @@ -59,10 +64,15 @@ export class ArticleServiceImpl implements ArticleService {
return await this.articleTagRepository.getAll(take, cursor)
}

/**
* Add a tag to an article
*
* @throws {ArticleNotFoundError} if the article does not exist
*/
async addTag(id: ArticleId, tag: ArticleTagName): Promise<void> {
const match = await this.articleRepository.getById(id)
if (match === undefined) {
throw new NotFoundError(`Article with ID:${id} not found`)
throw new ArticleNotFoundError(id)
}
let name = await this.articleTagRepository.getByName(tag)
if (name === undefined) {
Expand All @@ -71,10 +81,15 @@ export class ArticleServiceImpl implements ArticleService {
return await this.articleTagLinkRepository.add(id, name.name)
}

/**
* Remove a tag from an article
*
* @throws {ArticleNotFoundError} if the article does not exist
*/
async removeTag(id: ArticleId, tag: ArticleTagName): Promise<void> {
const match = await this.articleRepository.getById(id)
if (match === undefined) {
throw new NotFoundError(`Article with ID:${id} not found`)
throw new ArticleNotFoundError(id)
}
await this.articleTagLinkRepository.remove(id, tag)
const articlesWithTag = await this.articleRepository.getByTags([tag], 1)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { randomUUID } from "crypto"
import { Kysely } from "kysely"
import { type Committee } from "@dotkomonline/types"
import { NotFoundError } from "../../../errors/errors"
import { CommitteeRepositoryImpl } from "../committee-repository"
import { CommitteeServiceImpl } from "../committee-service"
import { CommitteeNotFoundError } from "../committee-error"

describe("CommitteeService", () => {
const db = vi.mocked(Kysely.prototype)
Expand All @@ -28,6 +28,6 @@ describe("CommitteeService", () => {
it("does not find non-existent committees", async () => {
const id = randomUUID()
vi.spyOn(committeeRepository, "getById").mockResolvedValueOnce(undefined)
await expect(async () => committeeService.getCommittee(id)).rejects.toThrowError(NotFoundError)
await expect(async () => committeeService.getCommittee(id)).rejects.toThrowError(CommitteeNotFoundError)
})
})
8 changes: 8 additions & 0 deletions packages/core/src/modules/committee/committee-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ApplicationError } from "../../error"
import { PROBLEM_DETAILS } from "../../http-problem-details"

export class CommitteeNotFoundError extends ApplicationError {
constructor(id: string) {
super(PROBLEM_DETAILS.NotFound, `Committee with ID:${id} not found`)
}
}
9 changes: 7 additions & 2 deletions packages/core/src/modules/committee/committee-service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type Committee, type CommitteeId, type CommitteeWrite } from "@dotkomonline/types"
import { type CommitteeRepository } from "./committee-repository"
import { NotFoundError } from "../../errors/errors"
import { type Collection, type Pageable } from "../../utils/db-utils"
import { CommitteeNotFoundError } from "./committee-error"

export interface CommitteeService {
getCommittee(id: CommitteeId): Promise<Committee>
Expand All @@ -12,10 +12,15 @@ export interface CommitteeService {
export class CommitteeServiceImpl implements CommitteeService {
constructor(private readonly committeeRepository: CommitteeRepository) {}

/**
* Get a committee by its id
*
* @throws {CommitteeNotFoundError} if the committee does not exist
*/
async getCommittee(id: CommitteeId) {
const committee = await this.committeeRepository.getById(id)
if (!committee) {
throw new NotFoundError(`Company with ID:${id} not found`)
throw new CommitteeNotFoundError(id)
}
return committee
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { randomUUID } from "crypto"
import { type Company } from "@dotkomonline/types"
import { Kysely } from "kysely"
import { NotFoundError } from "../../../errors/errors"
import { CompanyRepositoryImpl } from "../company-repository"
import { CompanyServiceImpl } from "../company-service"
import { CompanyNotFoundError } from "../company-error"

describe("CompanyService", () => {
const db = vi.mocked(Kysely.prototype, true)
Expand Down Expand Up @@ -31,7 +31,7 @@ describe("CompanyService", () => {
it("fails on unknown id", async () => {
const unknownID = randomUUID()
vi.spyOn(companyRepository, "getById").mockResolvedValueOnce(undefined)
await expect(companyService.getCompany(unknownID)).rejects.toThrow(NotFoundError)
await expect(companyService.getCompany(unknownID)).rejects.toThrow(CompanyNotFoundError)
expect(companyRepository.getById).toHaveBeenCalledWith(unknownID)
})
})
8 changes: 8 additions & 0 deletions packages/core/src/modules/company/company-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ApplicationError } from "../../error"
import { PROBLEM_DETAILS } from "../../http-problem-details"

export class CompanyNotFoundError extends ApplicationError {
constructor(id: string) {
super(PROBLEM_DETAILS.NotFound, `Company with ID:${id} not found`)
}
}
8 changes: 4 additions & 4 deletions packages/core/src/modules/company/company-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const mapToCompany = (payload: Selectable<Database["company"]>): Company
export interface CompanyRepository {
getById(id: CompanyId): Promise<Company | undefined>
getAll(take: number, cursor?: Cursor): Promise<Company[]>
create(values: CompanyWrite): Promise<Company | undefined>
create(values: CompanyWrite): Promise<Company>
update(id: CompanyId, data: CompanyWrite): Promise<Company>
}

Expand All @@ -26,9 +26,9 @@ export class CompanyRepositoryImpl implements CompanyRepository {
return companies.map(mapToCompany)
}

async create(data: CompanyWrite): Promise<Company | undefined> {
const company = await this.db.insertInto("company").values(data).returningAll().executeTakeFirst()
return company ? mapToCompany(company) : company
async create(data: CompanyWrite): Promise<Company> {
const company = await this.db.insertInto("company").values(data).returningAll().executeTakeFirstOrThrow()
return mapToCompany(company)
}

async update(id: CompanyId, data: Omit<CompanyWrite, "id">): Promise<Company> {
Expand Down
12 changes: 7 additions & 5 deletions packages/core/src/modules/company/company-service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type Company, type CompanyId, type CompanyWrite } from "@dotkomonline/types"
import { type CompanyRepository } from "./company-repository"
import { NotFoundError } from "../../errors/errors"
import { type Cursor } from "../../utils/db-utils"
import { CompanyNotFoundError } from "./company-error"

export interface CompanyService {
getCompany(id: CompanyId): Promise<Company>
Expand All @@ -13,10 +13,15 @@ export interface CompanyService {
export class CompanyServiceImpl implements CompanyService {
constructor(private readonly companyRepository: CompanyRepository) {}

/**
* Get a company by its id
*
* @throws {CompanyNotFoundError} if the company does not exist
*/
async getCompany(id: CompanyId): Promise<Company> {
const company = await this.companyRepository.getById(id)
if (!company) {
throw new NotFoundError(`Company with ID:${id} not found`)
throw new CompanyNotFoundError(id)
}
return company
}
Expand All @@ -28,9 +33,6 @@ export class CompanyServiceImpl implements CompanyService {

async createCompany(payload: CompanyWrite): Promise<Company> {
const company = await this.companyRepository.create(payload)
if (!company) {
throw new Error("Failed to create company")
}
return company
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { randomUUID } from "crypto"
import { type Event } from "@dotkomonline/types"
import { describe, vi } from "vitest"
import { Kysely } from "kysely"
import { NotFoundError } from "../../../errors/errors"
import { AttendanceRepositoryImpl } from "../attendance-repository"
import { EventRepositoryImpl } from "../event-repository"
import { EventServiceImpl } from "../event-service"
import { EventNotFoundError } from "../event-error"

export const eventPayload: Omit<Event, "id"> = {
title: "Kotlin og spillutvikling med Bekk",
Expand Down Expand Up @@ -47,7 +47,7 @@ describe("EventService", () => {
const id = randomUUID()
vi.spyOn(eventRepository, "getById").mockResolvedValueOnce(undefined)
const missing = eventService.getEventById(id)
await expect(missing).rejects.toThrow(NotFoundError)
await expect(missing).rejects.toThrow(EventNotFoundError)
vi.spyOn(eventRepository, "getById").mockResolvedValueOnce({ id, ...eventPayload })
const real = await eventService.getEventById(id)
expect(real).toEqual({ id, ...eventPayload })
Expand Down
15 changes: 13 additions & 2 deletions packages/core/src/modules/event/attendance-service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type AttendanceId, type Attendee, type EventId, type UserId } from "@dotkomonline/types"
import { type AttendanceRepository } from "./attendance-repository"
import { AttendeeNotFoundError } from "./attendee-error"

export interface AttendanceService {
canAttend(eventId: EventId): Promise<Date | undefined>
Expand Down Expand Up @@ -27,10 +28,15 @@ export class AttendanceServiceImpl implements AttendanceService {
return attendee
}

/**
* Register for an attendance
*
* @throws {AttendeeNotFoundError} if attendee is not found
*/
async registerForAttendance(_userId: UserId, _attendanceId: AttendanceId, _attended: boolean) {
const attendee = await this.attendanceRepository.getAttendeeByIds(_userId, _attendanceId)
if (!attendee) {
throw new Error("Attendee not found")
throw new AttendeeNotFoundError(_userId)
}
const attendedAttendee = await this.attendanceRepository.updateAttendee(
{ ...attendee, attended: _attended },
Expand All @@ -40,10 +46,15 @@ export class AttendanceServiceImpl implements AttendanceService {
return attendedAttendee
}

/**
* Add a choice to an attendee
*
* @throws {AttendeeNotFoundError} if attendee is not found
*/
async addChoice(eventId: string, attendanceId: string, questionId: string, choiceId: string) {
const attendee = await this.attendanceRepository.getAttendeeByIds(eventId, attendanceId)
if (!attendee) {
throw new Error("Attendee not found")
throw new AttendeeNotFoundError(eventId)
}
const choice = await this.attendanceRepository.addChoice(eventId, attendanceId, questionId, choiceId)
return choice
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/modules/event/attendee-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ApplicationError } from "../../error"
import { PROBLEM_DETAILS } from "../../http-problem-details"

export class AttendeeNotFoundError extends ApplicationError {
constructor(id: string) {
super(PROBLEM_DETAILS.NotFound, `Attendee with ID:${id} not found`)
}
}
Loading

1 comment on commit 852260d

@vercel
Copy link

@vercel vercel bot commented on 852260d Mar 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

invoicification – ./apps/invoicification

invoicification-dotkom.vercel.app
invoicification-git-main-dotkom.vercel.app
invoicification.vercel.app
faktura.online.ntnu.no

Please sign in to comment.