Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Error handling that does not suck. #3

Open
wants to merge 1 commit into
base: ioc-for-real
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion recipe-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,10 @@
},
"dependencies": {
"@tsoa/runtime": "^5.0.0",
"cucumber-messages": "npm:@cucumber/messages@latest",
"express": "^4.18.2",
"gherkin": "npm:@cucumber/gherkin@latest",
"cucumber-messages": "npm:@cucumber/messages@latest",
"http-status": "^1.6.2",
"inversify": "^6.0.1",
"inversify-binding-decorators": "^4.0.0",
"mongoose": "^7.2.0",
Expand Down
62 changes: 62 additions & 0 deletions recipe-api/src/common/error/controller-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import httpStatus from "http-status";

interface ControllerErrorHandler {
handle: (error: unknown) => ControllerError;
}

export interface ControllerError {
error: { name: string; message?: string };
}

export const mapControllerErrors = (
mapping: Record<string, number>,
{
setStatus,
logError,
}: { setStatus: (code: number) => void; logError?: (error: unknown) => void }
): ControllerErrorHandler => {
logError = logError || console.error;
mapping = {
...mapping,
ZodError: httpStatus.BAD_REQUEST,
};

return {
handle: (error: unknown) => {
logError?.(error);
const name =
error &&
typeof error === "object" &&
"name" in error &&
typeof error.name === "string" &&
error.name in mapping
? error.name
: "InternalServerError";
const message =
error &&
typeof error === "object" &&
"message" in error &&
typeof error.message === "string"
? error.message
: "Internal Server Error";
const isHumanReadable =
error &&
typeof error === "object" &&
"isHumanReadable" in error &&
typeof error.isHumanReadable === "boolean"
? error.isHumanReadable
: false;
const code =
(!error ? httpStatus.INTERNAL_SERVER_ERROR : mapping[name]) ||
httpStatus.INTERNAL_SERVER_ERROR;
setStatus(code);

return {
error: {
name,
...(isHumanReadable ? { message } : {}),
},
};
},
};
};
3 changes: 3 additions & 0 deletions recipe-api/src/common/error/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./controller-errors";
export * from "./service-errors";
export * from "./make-error";
8 changes: 8 additions & 0 deletions recipe-api/src/common/error/make-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const makeError = <TErrorName extends string>(
name: TErrorName,
message?: string
) => ({
name,
message,
isHumanReadable: true,
});
51 changes: 51 additions & 0 deletions recipe-api/src/common/error/service-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { makeError } from "./make-error";

export type ServiceError = { name: string; message?: string };

interface ServiceErrorHandler {
handle: (error: unknown) => ServiceError;
}

export const mapServiceErrors = (
mapping: Record<string, ReturnType<typeof makeError>>,
{ logError }: { logError?: (error: unknown) => void } = {}
): ServiceErrorHandler => {
logError = logError || console.error;
mapping = {
...mapping,
};

return {
handle: (error: unknown) => {
logError?.(error);
const name =
error &&
typeof error === "object" &&
"name" in error &&
typeof error.name === "string" &&
error.name in mapping
? error.name
: "InternalServerError";
const message =
error &&
typeof error === "object" &&
"message" in error &&
typeof error.message === "string"
? error.message
: "Internal Server Error";
const isHumanReadable =
error &&
typeof error === "object" &&
"isHumanReadable" in error &&
typeof error.isHumanReadable === "boolean"
? error.isHumanReadable
: false;
return (
mapping[name] || {
name,
...(isHumanReadable ? { message } : {}),
}
);
},
};
};
65 changes: 54 additions & 11 deletions recipe-api/src/domains/ingredient/ingredient.controller.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import httpStatus from "http-status";
import {
Body,
Controller,
Expand All @@ -6,57 +7,99 @@ import {
Patch,
Path,
Post,
Response,
Route,
SuccessResponse,
} from "tsoa";
import { Ingredient } from "./ingredient.model";
import { IRepository, Model, iocResolver } from "../../common";
import { ControllerError, mapControllerErrors } from "../../common/error";
import { Errors } from "./ingredient.service";

type TIngredient = Ingredient<{ name: string }>;
type TService = {
repo: IRepository<Model<TIngredient>>;
getIngredients: () => Promise<Ingredient<{ name: string }>[]>;
};

@Route("ingredients")
export class IngredientController extends Controller {
private errors = mapControllerErrors(
{
[Errors.ValidationError.name]: httpStatus.BAD_REQUEST,
[Errors.IngredientConflictError.name]: httpStatus.CONFLICT,
[Errors.IngredientDoesNotTasteGoodError.name]: httpStatus.FORBIDDEN,
},
{
setStatus: this.setStatus.bind(this),
}
);

constructor(
private service: TService = iocResolver.resolve("service:ingredients")?.()
) {
super();
}

@Get()
public async getIngredients(): Promise<TIngredient[]> {
return this.service.repo.all({});
@Response<ControllerError>(400, "Bad Request e.g.")
@Response<ControllerError>(
403,
`Forbidden e.g. ${[Errors.IngredientDoesNotTasteGoodError.name].join(", ")}`
)
public async getIngredients(): Promise<TIngredient[] | ControllerError> {
try {
return await this.service.getIngredients();
} catch (err) {
return this.errors.handle(err);
}
}

@Get("{ingredientId}")
public async getIngredientById(
@Path() ingredientId: string
): Promise<TIngredient> {
return this.service.repo.byQuery({ _id: ingredientId });
): Promise<TIngredient | ControllerError> {
try {
return await this.service.repo.byQuery({ _id: ingredientId });
} catch (err) {
return this.errors.handle(err);
}
}

@SuccessResponse("201", "Created") // Custom success response
@Post()
public async createIngredient(
@Body() requestBody: Omit<TIngredient, "nutrients">
): Promise<TIngredient> {
this.setStatus(201); // set return status 201
return this.service.repo.create(requestBody);
): Promise<TIngredient | ControllerError> {
try {
this.setStatus(201); // set return status 201
return await this.service.repo.create(requestBody);
} catch (err) {
return this.errors.handle(err);
}
}

@Patch("{ingredientId}")
public async updateIngredient(
@Path() ingredientId: string,
@Body() requestBody: Partial<Omit<TIngredient, "nutrients">>
): Promise<TIngredient> {
return this.service.repo.update({ _id: ingredientId }, requestBody);
): Promise<TIngredient | ControllerError> {
try {
return await this.service.repo.update({ _id: ingredientId }, requestBody);
} catch (err) {
return this.errors.handle(err);
}
}

@SuccessResponse("204", "No Content")
@Delete("{ingredientId}")
public async deleteIngredient(@Path() ingredientId: string): Promise<void> {
await this.service.repo.delete({ _id: ingredientId });
public async deleteIngredient(
@Path() ingredientId: string
): Promise<void | ControllerError> {
try {
await this.service.repo.delete({ _id: ingredientId });
} catch (err) {
return this.errors.handle(err);
}
}
}
32 changes: 29 additions & 3 deletions recipe-api/src/domains/ingredient/ingredient.service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,35 @@
import { injectable } from "inversify";
import { ioc } from "../../common";
import { iocResolver } from "../../common";
import { makeError, mapServiceErrors } from "../../common/error";
import { Ingredient } from "./ingredient.model";

const resolve = (): ServiceResolver => ioc.get("resolve");
export const Errors = {
ValidationError: makeError("ValidationError", "Validation error"),
IngredientConflictError: makeError(
"IngredientConflictError",
"Ingredient already exists"
),
IngredientDoesNotTasteGoodError: makeError(
"IngredientDoesNotTasteGoodError",
"Ingredient does not taste good"
),
};

@injectable()
export class IngredientService {
constructor(public repo = resolve()("repo:ingredients")?.()) {}
private errors = mapServiceErrors({
DBError: Errors.IngredientDoesNotTasteGoodError,
ValidationError: Errors.ValidationError,
MongoError: Errors.IngredientConflictError,
});

constructor(public repo = iocResolver.resolve("repo:ingredients")?.()) {}

public async getIngredients(): Promise<Ingredient<{ name: string }>[]> {
try {
return await this.repo.all({});
} catch (err: unknown) {
throw this.errors.handle(err);
}
}
}
8 changes: 8 additions & 0 deletions recipe-api/src/tsoa/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ const models: TsoaRoute.Models = {
"type": {"ref":"Ingredient__name-string__","validators":{}},
},
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
"ControllerError": {
"dataType": "refObject",
"properties": {
"error": {"dataType":"nestedObjectLiteral","nestedProperties":{"message":{"dataType":"string"},"name":{"dataType":"string","required":true}},"required":true},
},
"additionalProperties": false,
},
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
"Pick_TIngredient.Exclude_keyofTIngredient.nutrients__": {
"dataType": "refAlias",
"type": {"dataType":"nestedObjectLiteral","nestedProperties":{"name":{"dataType":"string","required":true},"calories":{"dataType":"double","required":true}},"validators":{}},
Expand Down