diff --git a/.gitignore b/.gitignore index b48fca3..e28467e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,4 @@ dist/ coverage .env data -prisma/seed.ts public/*.jpeg diff --git a/examples.http b/examples.http new file mode 100644 index 0000000..9ff63c8 --- /dev/null +++ b/examples.http @@ -0,0 +1,73 @@ +@BASE_URL=http://localhost:5000/api + + +### +# @name createWishlistFirst +POST {{BASE_URL}}/wishlist +Content-Type: application/json + +{ + "title": "Junior", + "imageSrc": "https://unsplash.com/photos/JZ51o_-UOY8/download?force=true&w=200", + "slugUrlText": "junior" +} + +### +# @name createWishlistSecond +POST {{BASE_URL}}/wishlist +Content-Type: application/json + +{ + "title": "Wedding", + "imageSrc": "https://unsplash.com/photos/8vaQKYnawHw/download?ixid=MnwxMjA3fDB8MXxhbGx8fHx8fHx8fHwxNjQ0MDQ4MTIy&force=true&w=200", + "description": "We are getting married", + "slugUrlText": "wedding" +} + +### +# @name getWishlists +GET {{BASE_URL}}/wishlist + +### +# @name getFirstWishlist +GET {{BASE_URL}}/wishlist/{{getWishlists.response.body.0.slugUrlText}} + +### +# @name updateFirstWishlist +PUT {{BASE_URL}}/wishlist/{{getWishlists.response.body.0.id}} +Content-Type: application/json + +{ + "title": "Junior", + "imageSrc": "https://unsplash.com/photos/JZ51o_-UOY8/download?force=true&w=200", + "description": "Juniors Wishlist", + "slugUrlText": "junior" +} + +### +# @name addItemToFirstWishlist +POST {{BASE_URL}}/wishlist/{{getWishlists.response.body.0.id}}/item +Content-Type: application/json + +{ + "title": "Goldfish 40442 | BrickHeadz", + "url": "https://www.lego.com/en-de/product/goldfish-40442", + "imageSrc": "https://www.lego.com/cdn/cs/set/assets/blt1fc37afef51cfa9f/40442.jpg?fit=bounds&format=jpg&quality=80&width=1500&height=1500&dpr=1", + "description": "Cute goldfish and fry, build-and-display BrickHeadz™ model" +} + +### +# @name updateItemOnFirstWishlist +PUT {{BASE_URL}}/wishlist/{{getWishlists.response.body.0.id}}/item/1 +Content-Type: application/json + +{ + "title": "Goldfish | BrickHeadz", + "url": "https://www.lego.com/en-de/product/goldfish-40442", + "imageSrc": "https://www.lego.com/cdn/cs/set/assets/blt1fc37afef51cfa9f/40442.jpg?fit=bounds&format=jpg&quality=80&width=1500&height=1500&dpr=1", + "description": "Cute goldfish and fry, build-and-display BrickHeadz™ model" +} + +### +# @name deleteItemToFirstWishlist +DELETE {{BASE_URL}}/wishlist/{{getWishlists.response.body.0.id}}/item/2 diff --git a/prisma/migrations/20220129174151_first_migration/migration.sql b/prisma/migrations/20220205134511_first_migration/migration.sql similarity index 79% rename from prisma/migrations/20220129174151_first_migration/migration.sql rename to prisma/migrations/20220205134511_first_migration/migration.sql index f84bb69..d37838a 100644 --- a/prisma/migrations/20220129174151_first_migration/migration.sql +++ b/prisma/migrations/20220205134511_first_migration/migration.sql @@ -4,17 +4,17 @@ CREATE TABLE "Wishlist" ( "title" TEXT NOT NULL, "imageSrc" TEXT NOT NULL, "slugUrlText" TEXT NOT NULL, - "description" TEXT + "description" TEXT NOT NULL DEFAULT '' ); -- CreateTable CREATE TABLE "Item" ( "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "title" TEXT NOT NULL, - "url" TEXT, - "imageSrc" TEXT, + "url" TEXT NOT NULL DEFAULT '', + "imageSrc" TEXT NOT NULL DEFAULT '', "description" TEXT NOT NULL, - "comment" TEXT, + "comment" TEXT NOT NULL DEFAULT '', "bought" BOOLEAN NOT NULL DEFAULT false, "wishlistId" TEXT NOT NULL, CONSTRAINT "Item_wishlistId_fkey" FOREIGN KEY ("wishlistId") REFERENCES "Wishlist" ("id") ON DELETE RESTRICT ON UPDATE CASCADE diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7616486..492ab53 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,18 +15,18 @@ model Wishlist { title String imageSrc String slugUrlText String @unique - description String? + description String @default("") items Item[] } model Item { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) title String - url String? - imageSrc String? + url String @default("") + imageSrc String @default("") description String - comment String? + comment String @default("") bought Boolean @default(false) - wishlist Wishlist @relation(fields: [wishlistId], references: [id]) + wishlist Wishlist @relation(fields: [wishlistId], references: [id]) wishlistId String } diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..b404659 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,60 @@ +import { PrismaClient, Prisma } from '@prisma/client' + +const prisma = new PrismaClient() + +const wishlistData: Prisma.WishlistCreateInput[] = [ + { + title: 'Junior', + imageSrc: + 'https://unsplash.com/photos/JZ51o_-UOY8/download?force=true&w=200', + description: '', + slugUrlText: 'junior', + items: { + create: [ + { + title: 'Goldfish 40442 | BrickHeadz', + url: 'https://www.lego.com/en-de/product/goldfish-40442', + imageSrc: + 'https://www.lego.com/cdn/cs/set/assets/blt1fc37afef51cfa9f/40442.jpg?fit=bounds&format=jpg&quality=80&width=1500&height=1500&dpr=1', + description: + 'Cute goldfish and fry, build-and-display BrickHeadz™ model', + comment: '', + }, + ], + }, + }, + { + title: 'Wedding', + imageSrc: + 'https://unsplash.com/photos/8vaQKYnawHw/download?ixid=MnwxMjA3fDB8MXxhbGx8fHx8fHx8fHwxNjQ0MDQ4MTIy&force=true&w=200', + description: 'We are getting married', + slugUrlText: 'wedding', + }, + { + title: '40th birthday', + imageSrc: + 'https://unsplash.com/photos/poH6OvcEeXE/download?ixid=MnwxMjA3fDB8MXxzZWFyY2h8NHx8YmlydGhkYXl8fDB8fHx8MTY0NDA1NDEzNA&force=true&w=200', + description: 'We are getting married', + slugUrlText: '40th-birthday', + }, +] + +async function main() { + console.log(`Start seeding ...`) + for (const u of wishlistData) { + const wishlist = await prisma.wishlist.create({ + data: u, + }) + console.log(`Created wishlist with id: ${wishlist.id}`) + } + console.log(`Seeding finished.`) +} + +main() + .catch((e) => { + console.error(e) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/src/api/config/errors/index.ts b/src/api/config/errors/index.ts new file mode 100644 index 0000000..9d91200 --- /dev/null +++ b/src/api/config/errors/index.ts @@ -0,0 +1,56 @@ +import { FastifyRequest, FastifyReply, FastifyError } from 'fastify' +import { Prisma } from '@prisma/client' + +const errorIs = (e: unknown, c: string) => + e instanceof Prisma.PrismaClientKnownRequestError && e.code === c + +export const defaultErrorHandler = ( + error: FastifyError, + request: FastifyRequest, + reply: FastifyReply +) => { + if (error.validation) { + error.code = '400' + reply.send(error) + } else if (errorIs(error, 'P2002')) { + reply.send( + uniqueKeyError( + // @ts-expect-error: Object is possibly 'undefined' + `${error.meta.target[0] || 'One of the fields'} has to be unique` + ) + ) + } else if (errorIs(error, 'P2025')) { + reply.callNotFound() + } else { + request.log.error(error) + const e = new httpError('unexpected error', 500, '500') + reply.send(e) + } +} + +export const notFoundHandler = ( + request: FastifyRequest, + reply: FastifyReply +) => { + reply.send(notFoundError()) +} + +class httpError extends Error { + code: string + statusCode: number + constructor(message: string, statusCode: number, code: string) { + super(message) + this.name = this.constructor.name + Error.captureStackTrace(this, this.constructor) + this.statusCode = statusCode + this.code = code + } +} + +const notFoundError = () => { + return new httpError('Not Found', 404, '404') +} + +const uniqueKeyError = (msg: string, code = '4001') => { + return new httpError(msg, 422, code) +} diff --git a/src/api/config/schemas/index.ts b/src/api/config/schemas/index.ts new file mode 100644 index 0000000..5284f07 --- /dev/null +++ b/src/api/config/schemas/index.ts @@ -0,0 +1 @@ +export * from './wishlist' diff --git a/src/api/config/schemas/wishlist.ts b/src/api/config/schemas/wishlist.ts new file mode 100644 index 0000000..3e575fc --- /dev/null +++ b/src/api/config/schemas/wishlist.ts @@ -0,0 +1,53 @@ +export const wishlistItemRequestSchema = { + type: 'object', + additionalProperties: false, + required: ['title', 'description'], + properties: { + title: { type: 'string' }, + url: { type: 'string' }, + imageSrc: { type: 'string' }, + description: { type: 'string' }, + comment: { type: 'string' }, + bought: { type: 'boolean' }, + }, +} + +export const wishlistItemResponseSchema = { + type: 'object', + properties: { + id: { type: 'number' }, + title: { type: 'string' }, + url: { type: 'string' }, + imageSrc: { type: 'string' }, + description: { type: 'string' }, + comment: { type: 'string' }, + bought: { type: 'boolean' }, + wishlistId: { type: 'string' }, + }, +} + +export const wishlistRequestSchema = { + type: 'object', + additionalProperties: false, + required: ['title', 'imageSrc', 'slugUrlText'], + properties: { + title: { type: 'string' }, + imageSrc: { type: 'string' }, + description: { type: 'string' }, + slugUrlText: { type: 'string' }, + }, +} +export const wishlistResponseSchema = { + type: 'object', + properties: { + id: { type: 'string' }, + title: { type: 'string' }, + imageSrc: { type: 'string' }, + description: { type: 'string' }, + slugUrlText: { type: 'string' }, + items: { + type: 'array', + items: wishlistItemResponseSchema, + }, + }, +} diff --git a/src/api/models/wishlist/index.ts b/src/api/models/wishlist/index.ts index 35b5ddc..183f227 100644 --- a/src/api/models/wishlist/index.ts +++ b/src/api/models/wishlist/index.ts @@ -1,15 +1,13 @@ import { prisma } from '../../services' +import { Wishlist, WishlistItem } from '@/types' export default { - getAll: async (): Promise => { - return await prisma.client.wishlist.findMany({ + getAll: async (): Promise => { + return (await prisma.client.wishlist.findMany({ include: { items: false }, - }) + })) as Wishlist[] }, - getBySlugUrlText: async ( - value: string, - includeItems = false - ): Promise => { + getBySlugUrlText: async (value: string, includeItems = false) => { return await prisma.client.wishlist.findUnique({ where: { slugUrlText: value, @@ -17,6 +15,44 @@ export default { include: { items: includeItems }, }) }, + create: async (payload: Wishlist) => { + return await prisma.client.wishlist.create({ + data: payload, + }) + }, + update: async (id: string, payload: Wishlist) => { + return await prisma.client.wishlist.update({ + where: { + id: id, + }, + data: { + ...payload, + }, + }) + }, + delete: async (id: string) => { + return await prisma.client.wishlist.delete({ + where: { + id: id, + }, + }) + }, + createItem: async (wishlistId: string, payload: WishlistItem) => { + const wishlist = await prisma.client.wishlist.update({ + where: { + id: wishlistId, + }, + data: { + items: { + create: { + ...payload, + }, + }, + }, + include: { items: true }, + }) + return wishlist.items.pop() + }, // eslint-disable-next-line @typescript-eslint/no-explicit-any updateItem: async (itemId: number, payload: any) => { return await prisma.client.item.update({ @@ -28,4 +64,11 @@ export default { }, }) }, + deleteItem: async (itemId: number) => { + return await prisma.client.item.delete({ + where: { + id: itemId, + }, + }) + }, } diff --git a/src/api/routes/index.ts b/src/api/routes/index.ts index 222f45c..d88f405 100644 --- a/src/api/routes/index.ts +++ b/src/api/routes/index.ts @@ -1,10 +1,13 @@ import { FastifyInstance } from 'fastify' import { default as wishlistRoute } from './wishlist/' +import { defaultErrorHandler, notFoundHandler } from '../config/errors' export default { register: (app: FastifyInstance) => { return app.register( async (app) => { + await app.setNotFoundHandler(notFoundHandler) + await app.setErrorHandler(defaultErrorHandler) await app.register(wishlistRoute, { prefix: '/wishlist' }) }, { prefix: '/api' } diff --git a/src/api/routes/wishlist/create.ts b/src/api/routes/wishlist/create.ts new file mode 100644 index 0000000..cf8117b --- /dev/null +++ b/src/api/routes/wishlist/create.ts @@ -0,0 +1,56 @@ +import { Wishlist, WishlistItem } from '@/types' +import { FastifyRequest, FastifyReply, RouteOptions } from 'fastify' +import { wishlist } from '../../models' +import { + wishlistItemRequestSchema, + wishlistItemResponseSchema, + wishlistRequestSchema, + wishlistResponseSchema, +} from '../../config/schemas' + +interface createItemRequest extends FastifyRequest { + params: { + wishlistId: string + } +} + +export const createList = { + method: 'POST', + url: '/', + schema: { + body: wishlistRequestSchema, + response: { + 201: wishlistResponseSchema, + }, + }, + handler: async (request: FastifyRequest, reply: FastifyReply) => { + request.log.debug(request.body) + const item = await wishlist.create(request.body as Wishlist) + reply.code(201).send(item) + }, +} + +export const createItem = { + method: 'POST', + url: '/:wishlistId/item', + schema: { + body: wishlistItemRequestSchema, + params: { + type: 'object', + properties: { + wishlistId: { type: 'string' }, + }, + }, + response: { + 201: wishlistItemResponseSchema, + }, + }, + handler: async (request: createItemRequest, reply: FastifyReply) => { + request.log.debug(request.body) + const item = await wishlist.createItem( + request.params.wishlistId, + request.body as WishlistItem + ) + reply.code(201).send(item) + }, +} diff --git a/src/api/routes/wishlist/delete.ts b/src/api/routes/wishlist/delete.ts new file mode 100644 index 0000000..ebeb9e9 --- /dev/null +++ b/src/api/routes/wishlist/delete.ts @@ -0,0 +1,50 @@ +import { FastifyRequest, FastifyReply, RouteOptions } from 'fastify' +import { wishlist } from '../../models' + +interface deleteRequest extends FastifyRequest { + params: { + wishlistId: string + } +} + +interface deleteItemRequest extends FastifyRequest { + params: { + wishlistId: string + itemId: number + } +} + +export const deleteList = { + method: 'DELETE', + url: '/:wishlistId', + schema: { + params: { + type: 'object', + properties: { + wishlistId: { type: 'string' }, + }, + }, + }, + handler: async (request: deleteRequest, reply: FastifyReply) => { + await wishlist.delete(request.params.wishlistId) + reply.code(204).send() + }, +} + +export const deleteItem = { + method: 'DELETE', + url: '/:wishlistId/item/:itemId', + schema: { + params: { + type: 'object', + properties: { + wishlistId: { type: 'string' }, + itemId: { type: 'number' }, + }, + }, + }, + handler: async (request: deleteItemRequest, reply: FastifyReply) => { + await wishlist.deleteItem(request.params.itemId) + reply.code(204).send() + }, +} diff --git a/src/api/routes/wishlist/index.ts b/src/api/routes/wishlist/index.ts index 32fd362..13ad069 100644 --- a/src/api/routes/wishlist/index.ts +++ b/src/api/routes/wishlist/index.ts @@ -1,9 +1,16 @@ import { FastifyInstance } from 'fastify' import { getAll, getBySlugUrl } from './read' -import { updateItem } from './update' +import { updateList, updateItem } from './update' +import { createList, createItem } from './create' +import { deleteList, deleteItem } from './delete' export default async (app: FastifyInstance) => { await app.route(getAll) await app.route(getBySlugUrl) + await app.route(createList) + await app.route(createItem) + await app.route(updateList) await app.route(updateItem) + await app.route(deleteList) + await app.route(deleteItem) } diff --git a/src/api/routes/wishlist/read.ts b/src/api/routes/wishlist/read.ts index 389ad37..3bc6e80 100644 --- a/src/api/routes/wishlist/read.ts +++ b/src/api/routes/wishlist/read.ts @@ -1,22 +1,15 @@ import { FastifyRequest, FastifyReply, RouteOptions } from 'fastify' import { wishlist } from '../../models' +import { wishlistResponseSchema } from '../../config/schemas' -export const getAll = { +export const getAll = { method: 'GET', url: '/', schema: { response: { 200: { type: 'array', - items: { - properties: { - id: { type: 'string' }, - title: { type: 'string' }, - imageSrc: { type: 'string' }, - description: { type: 'string' }, - slugUrlText: { type: 'string' }, - }, - }, + items: wishlistResponseSchema, }, }, }, @@ -36,31 +29,7 @@ export const getBySlugUrl = { url: '/:slugText', schema: { response: { - 200: { - type: 'object', - properties: { - id: { type: 'string' }, - title: { type: 'string' }, - imageSrc: { type: 'string' }, - description: { type: 'string' }, - slugUrlText: { type: 'string' }, - items: { - type: 'array', - items: { - properties: { - id: { type: 'number' }, - title: { type: 'string' }, - url: { type: 'string' }, - imageSrc: { type: 'string' }, - description: { type: 'string' }, - comment: { type: 'string' }, - bought: { type: 'boolean' }, - wishlistId: { type: 'string' }, - }, - }, - }, - }, - }, + 200: wishlistResponseSchema, }, }, handler: async (request: GetBySlugUrlTextRequest, reply: FastifyReply) => { @@ -68,10 +37,7 @@ export const getBySlugUrl = { if (list) { return list } else { - return reply.code(404).send({ - error: 'notFound', - http: 404, - }) + return reply.callNotFound() } }, } diff --git a/src/api/routes/wishlist/update.ts b/src/api/routes/wishlist/update.ts index a8775fe..2f64817 100644 --- a/src/api/routes/wishlist/update.ts +++ b/src/api/routes/wishlist/update.ts @@ -1,58 +1,69 @@ +import { Wishlist } from '@/types' import { FastifyRequest, FastifyReply, RouteOptions } from 'fastify' import { wishlist } from '../../models' +import { + wishlistRequestSchema, + wishlistResponseSchema, + wishlistItemRequestSchema, + wishlistItemResponseSchema, +} from '../../config/schemas' -interface GetBySlugUrlTextRequest extends FastifyRequest { +interface updateRequest extends FastifyRequest { + params: { + wishlistId: string + } +} + +interface updateItemRequest extends FastifyRequest { params: { wishlistId: string itemId: number } } +export const updateList = { + method: 'PUT', + url: '/:wishlistId', + schema: { + body: wishlistRequestSchema, + params: { + type: 'object', + properties: { + wishlistId: { type: 'string' }, + }, + }, + response: { + 200: wishlistResponseSchema, + }, + }, + handler: async (request: updateRequest, reply: FastifyReply) => { + request.log.debug(request.body) + const item = await wishlist.update( + request.params.wishlistId, + request.body as Wishlist + ) + reply.code(201).send(item) + }, +} + export const updateItem = { method: 'PUT', url: '/:wishlistId/item/:itemId', schema: { - body: { + body: wishlistItemRequestSchema, + params: { type: 'object', - additionalProperties: false, properties: { - title: { type: 'string' }, - url: { type: 'string' }, - image: { type: 'string' }, - description: { type: 'string' }, - comment: { type: 'string' }, - bought: { type: 'boolean' }, + wishlistId: { type: 'string' }, + itemId: { type: 'number' }, }, }, response: { - 204: { - type: 'object', - properties: { - id: { type: 'number' }, - title: { type: 'string' }, - url: { type: 'string' }, - image: { type: 'string' }, - description: { type: 'string' }, - comment: { type: 'string' }, - bought: { type: 'boolean' }, - wishlistId: { type: 'string' }, - }, - }, + 200: wishlistItemResponseSchema, }, }, - handler: async (request: GetBySlugUrlTextRequest, reply: FastifyReply) => { + handler: async (request: updateItemRequest, reply: FastifyReply) => { request.log.debug(request.body) - const item = await wishlist.updateItem( - Number(request.params.itemId), - request.body - ) - if (item) { - return item - } else { - return reply.code(404).send({ - error: 'notFound', - http: 404, - }) - } + reply.send(await wishlist.updateItem(request.params.itemId, request.body)) }, } diff --git a/src/types.ts b/src/types.ts index de90a26..9962ef6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,12 +9,12 @@ export interface WishlistItem { wishlistId: boolean } export interface Wishlist { - id: string + id?: string title: string description: string imageSrc: string slugUrlText: string - items: WishlistItem[] + items?: WishlistItem[] } export interface TileProp { title: string diff --git a/src/views/DetailView.vue b/src/views/DetailView.vue index fe89828..73cf606 100644 --- a/src/views/DetailView.vue +++ b/src/views/DetailView.vue @@ -16,7 +16,7 @@ const { list, fetch, updateItem } = useWishlistStore() await fetch(route.params.slug as string) const notBoughtItems = computed(() => { - return list.value?.items.filter( + return list.value?.items?.filter( (item: WishlistItemType) => item.bought === false ) })