From 0d0807ed363114beba30d8a5cfd4925fbaa87746 Mon Sep 17 00:00:00 2001 From: Husen Date: Mon, 13 May 2024 07:11:12 +0700 Subject: [PATCH] feat: update user using put method Signed-off-by: Husen --- .devcontainer/devcontainer.json | 2 +- .devcontainer/docker-compose.yml | 2 +- backend/node/.env.example | 10 +- backend/node/__tests__/user.test.ts | 534 ++++++++++++++++++ .../src/core/entities/validation.entity.ts | 2 + .../node/src/infrastructure/config/auth.ts | 31 - .../server/express/handlers/user.handler.ts | 63 ++- .../express/middlewares/unique-user-email.ts | 8 +- .../server/express/routes/user.route.ts | 9 + .../server/express/schemas/user.schema.ts | 21 + openapi.yaml | 47 +- 11 files changed, 679 insertions(+), 50 deletions(-) delete mode 100644 backend/node/src/infrastructure/config/auth.ts diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8d23b2a..992ca0a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,7 +2,7 @@ "name": "Playground", "dockerComposeFile": "./docker-compose.yml", "service": "app", - "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "workspaceFolder": "/workspaces", "overrideCommand": true, "shutdownAction": "stopCompose", "features": { diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 75a1668..3788cb9 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -38,7 +38,7 @@ services: - playground redis: - image: docker.io/library/redis:7-alpine + image: docker.io/library/redis:7.2.4-alpine ports: - '${REDIS_PORT:-6379}:6379' volumes: diff --git a/backend/node/.env.example b/backend/node/.env.example index e17c7d8..c64ec12 100644 --- a/backend/node/.env.example +++ b/backend/node/.env.example @@ -1,7 +1,8 @@ APP_NAME=Playground APP_PORT=3000 +APP_URL=http://localhost:3000 -DB_HOST=127.0.0.1 +DB_HOST=postgres DB_PORT=5432 DB_USERNAME=postgres DB_PASSWORD=postgres @@ -13,10 +14,5 @@ AWS_DEFAULT_REGION=ap-southeast-1 AWS_ACCESS_KEY_ID=miniosudo AWS_SECRET_ACCESS_KEY=miniosudo -REDIS_HOST=127.0.0.1 +REDIS_HOST=redis REDIS_PORT=6379 - -JWT_ACCESS_SECRET=jwtaccesssecret -JWT_REFRESH_SECRET=jwtrefreshsecret -JWT_ISSUER=jwtaccessissuer -JWT_AUDIENCE=jwtaccessaudience diff --git a/backend/node/__tests__/user.test.ts b/backend/node/__tests__/user.test.ts index eb4ef73..b6d457e 100644 --- a/backend/node/__tests__/user.test.ts +++ b/backend/node/__tests__/user.test.ts @@ -880,3 +880,537 @@ describe('PATCH /users/{id}', () => { ); }); }); + +describe('PUT /users', () => { + test('should return error when request body is empty', async () => { + const response: SupertestResponse = await request + .put('/users') + .set('Accept', 'application/vnd.api+json'); + + expect(response.status).toStrictEqual( + StatusCodes.BAD_REQUEST + ); + expect(response.body).toStrictEqual({ + jsonapi: { + version: '1.1', + }, + errors: [ + { + status: StatusCodes.BAD_REQUEST.toString(), + title: ReasonPhrases.BAD_REQUEST, + detail: getErrorMessage('data.required'), + }, + ], + }); + }); + + test('should return error when data is empty object', async () => { + const response: SupertestResponse = await request + .put('/users') + .set('Accept', 'application/vnd.api+json') + .send({ + data: {}, + }); + + expect(response.status).toStrictEqual( + StatusCodes.BAD_REQUEST + ); + expect(response.body).toStrictEqual({ + jsonapi: { + version: '1.1', + }, + errors: [ + { + status: StatusCodes.BAD_REQUEST.toString(), + title: ReasonPhrases.BAD_REQUEST, + detail: getErrorMessage('data.type.required'), + }, + { + status: StatusCodes.BAD_REQUEST.toString(), + title: ReasonPhrases.BAD_REQUEST, + detail: getErrorMessage('data.id.required'), + }, + ], + }); + }); + + test('should return error when data.type is invalid', async () => { + const response: SupertestResponse = await request + .put('/users') + .set('Accept', 'application/vnd.api+json') + .send({ + data: { + type: 'user', + }, + }); + + expect(response.status).toStrictEqual( + StatusCodes.BAD_REQUEST + ); + expect(response.body).toStrictEqual({ + jsonapi: { + version: '1.1', + }, + errors: [ + { + status: StatusCodes.BAD_REQUEST.toString(), + title: ReasonPhrases.BAD_REQUEST, + detail: getErrorMessage('data.type.pattern'), + }, + { + status: StatusCodes.BAD_REQUEST.toString(), + title: ReasonPhrases.BAD_REQUEST, + detail: getErrorMessage('data.id.required'), + }, + ], + }); + }); + + test('should return error when data.attributes.id is missing', async () => { + const response: SupertestResponse = await request + .put('/users') + .set('Accept', 'application/vnd.api+json') + .send({ + data: { + type: 'users', + }, + }); + + expect(response.status).toStrictEqual( + StatusCodes.BAD_REQUEST + ); + expect(response.body).toStrictEqual({ + jsonapi: { + version: '1.1', + }, + errors: [ + { + status: StatusCodes.BAD_REQUEST.toString(), + title: ReasonPhrases.BAD_REQUEST, + detail: getErrorMessage('data.id.required'), + }, + ], + }); + }); + + test('should not update user', async () => { + const response: SupertestResponse> = await request + .put('/users') + .set('Accept', 'application/vnd.api+json') + .send({ + data: { + id: user.id, + type: 'users', + }, + }); + + expect(response.status).toStrictEqual(StatusCodes.OK); + expect>(response.body).toHaveProperty( + 'jsonapi.version', + '1.1' + ); + expect>(response.body).toHaveProperty( + 'data.id', + user.id + ); + expect>(response.body).toHaveProperty( + 'data.type', + 'users' + ); + expect>(response.body).toHaveProperty( + 'data.attributes.first_name', + user.first_name + ); + expect>(response.body).toHaveProperty( + 'data.attributes.last_name', + String(user.last_name) + ); + expect>(response.body).toHaveProperty( + 'data.attributes.nickname' + ); + expect>(response.body).toHaveProperty( + 'data.attributes.email', + user.email + ); + expect>(response.body).toHaveProperty( + 'data.attributes.photo', + null + ); + expect>(response.body).toHaveProperty( + 'data.attributes.avatar', + null + ); + expect>(response.body).toHaveProperty( + 'data.attributes.created_at' + ); + expect>(response.body).toHaveProperty( + 'data.attributes.updated_at' + ); + }); + + test('should return error when name is null', async () => { + const response: SupertestResponse = await request + .put(`/users`) + .set('Accept', 'application/vnd.api+json') + .send({ + data: { + type: 'users', + id: user.id, + attributes: { + first_name: null, + }, + }, + }); + + expect(response.status).toStrictEqual( + StatusCodes.BAD_REQUEST + ); + expect(response.body).toStrictEqual({ + jsonapi: { + version: '1.1', + }, + errors: [ + { + status: StatusCodes.BAD_REQUEST.toString(), + title: ReasonPhrases.BAD_REQUEST, + detail: getErrorMessage('data.attributes.first_name.type'), + }, + ], + }); + }); + + test('should return error when name is null', async () => { + const response: SupertestResponse = await request + .put(`/users`) + .set('Accept', 'application/vnd.api+json') + .send({ + data: { + id: user.id, + type: 'users', + attributes: { + first_name: null, + }, + }, + }); + + expect(response.status).toStrictEqual( + StatusCodes.BAD_REQUEST + ); + expect(response.body).toStrictEqual({ + jsonapi: { + version: '1.1', + }, + errors: [ + { + status: StatusCodes.BAD_REQUEST.toString(), + title: ReasonPhrases.BAD_REQUEST, + detail: getErrorMessage('data.attributes.first_name.type'), + }, + ], + }); + }); + + test('should return error when name is empty', async () => { + const response: SupertestResponse = await request + .put(`/users`) + .set('Accept', 'application/vnd.api+json') + .send({ + data: { + id: user.id, + type: 'users', + attributes: { + first_name: '', + }, + }, + }); + + expect(response.status).toStrictEqual( + StatusCodes.BAD_REQUEST + ); + expect(response.body).toStrictEqual({ + jsonapi: { + version: '1.1', + }, + errors: [ + { + status: StatusCodes.BAD_REQUEST.toString(), + title: ReasonPhrases.BAD_REQUEST, + detail: getErrorMessage('data.attributes.first_name.minLength'), + }, + ], + }); + }); + + test('should return error when name is invalid', async () => { + const response: SupertestResponse = await request + .put(`/users`) + .set('Accept', 'application/vnd.api+json') + .send({ + data: { + id: user.id, + type: 'users', + attributes: { + first_name: 12345, + }, + }, + }); + + expect(response.status).toStrictEqual( + StatusCodes.BAD_REQUEST + ); + expect(response.body).toStrictEqual({ + jsonapi: { + version: '1.1', + }, + errors: [ + { + status: StatusCodes.BAD_REQUEST.toString(), + title: ReasonPhrases.BAD_REQUEST, + detail: getErrorMessage('data.attributes.first_name.type'), + }, + ], + }); + }); + + test('should ok when username is null', async () => { + const response: SupertestResponse> = await request + .put(`/users`) + .set('Accept', 'application/vnd.api+json') + .send({ + data: { + id: user.id, + type: 'users', + attributes: { + nickname: null, + }, + }, + }); + + expect(response.status).toStrictEqual(StatusCodes.OK); + expect>(response.body).toHaveProperty( + 'jsonapi.version', + '1.1' + ); + expect>(response.body).toHaveProperty( + 'data.id', + user.id + ); + expect>(response.body).toHaveProperty( + 'data.type', + 'users' + ); + expect>(response.body).toHaveProperty( + 'data.attributes.first_name', + user.first_name + ); + expect>(response.body).toHaveProperty( + 'data.attributes.last_name', + String(user.last_name) + ); + expect>(response.body).toHaveProperty( + 'data.attributes.nickname', + null + ); + expect>(response.body).toHaveProperty( + 'data.attributes.email', + user.email + ); + expect>(response.body).toHaveProperty( + 'data.attributes.photo', + null + ); + expect>(response.body).toHaveProperty( + 'data.attributes.avatar', + null + ); + expect>(response.body).toHaveProperty( + 'data.attributes.created_at' + ); + expect>(response.body).toHaveProperty( + 'data.attributes.updated_at' + ); + }); + + test('should ok when username is empty', async () => { + const response: SupertestResponse> = await request + .put(`/users`) + .set('Accept', 'application/vnd.api+json') + .send({ + data: { + id: user.id, + type: 'users', + attributes: { + nickname: '', + }, + }, + }); + + expect(response.status).toStrictEqual(StatusCodes.OK); + expect>(response.body).toHaveProperty( + 'jsonapi.version', + '1.1' + ); + expect>(response.body).toHaveProperty( + 'data.id', + user.id + ); + expect>(response.body).toHaveProperty( + 'data.type', + 'users' + ); + expect>(response.body).toHaveProperty( + 'data.attributes.first_name', + user.first_name + ); + expect>(response.body).toHaveProperty( + 'data.attributes.last_name', + String(user.last_name) + ); + expect>(response.body).toHaveProperty( + 'data.attributes.nickname', + null + ); + expect>(response.body).toHaveProperty( + 'data.attributes.email', + user.email + ); + expect>(response.body).toHaveProperty( + 'data.attributes.photo', + null + ); + expect>(response.body).toHaveProperty( + 'data.attributes.avatar', + null + ); + expect>(response.body).toHaveProperty( + 'data.attributes.created_at' + ); + expect>(response.body).toHaveProperty( + 'data.attributes.updated_at' + ); + }); + + test('should return error when email is null', async () => { + const response: SupertestResponse = await request + .put(`/users`) + .set('Accept', 'application/vnd.api+json') + .send({ + data: { + id: user.id, + type: 'users', + attributes: { + email: null, + }, + }, + }); + + expect(response.status).toStrictEqual( + StatusCodes.BAD_REQUEST + ); + expect(response.body).toStrictEqual({ + jsonapi: { + version: '1.1', + }, + errors: [ + { + status: StatusCodes.BAD_REQUEST.toString(), + title: ReasonPhrases.BAD_REQUEST, + detail: getErrorMessage('data.attributes.email.type'), + }, + ], + }); + }); + + test('should return error when email is empty', async () => { + const response: SupertestResponse = await request + .put(`/users`) + .set('Accept', 'application/vnd.api+json') + .send({ + data: { + id: user.id, + type: 'users', + attributes: { + email: '', + }, + }, + }); + + expect(response.status).toStrictEqual( + StatusCodes.BAD_REQUEST + ); + expect(response.body).toStrictEqual({ + jsonapi: { + version: '1.1', + }, + errors: [ + { + status: StatusCodes.BAD_REQUEST.toString(), + title: ReasonPhrases.BAD_REQUEST, + detail: getErrorMessage('data.attributes.email.format'), + }, + ], + }); + }); + + test('should return error when email is invalid', async () => { + const response: SupertestResponse = await request + .put(`/users`) + .set('Accept', 'application/vnd.api+json') + .send({ + data: { + id: user.id, + type: 'users', + attributes: { + email: 12345, + }, + }, + }); + + expect(response.status).toStrictEqual( + StatusCodes.BAD_REQUEST + ); + expect(response.body).toStrictEqual({ + jsonapi: { + version: '1.1', + }, + errors: [ + { + status: StatusCodes.BAD_REQUEST.toString(), + title: ReasonPhrases.BAD_REQUEST, + detail: getErrorMessage('data.attributes.email.type'), + }, + ], + }); + }); + + test('should return error when email is invalid', async () => { + const response: SupertestResponse = await request + .put(`/users`) + .set('Accept', 'application/vnd.api+json') + .send({ + data: { + id: user.id, + type: 'users', + attributes: { + email: 'johndoe', + }, + }, + }); + + expect(response.status).toStrictEqual( + StatusCodes.BAD_REQUEST + ); + expect(response.body).toStrictEqual({ + jsonapi: { + version: '1.1', + }, + errors: [ + { + status: StatusCodes.BAD_REQUEST.toString(), + title: ReasonPhrases.BAD_REQUEST, + detail: getErrorMessage('data.attributes.email.format'), + }, + ], + }); + }); +}); diff --git a/backend/node/src/core/entities/validation.entity.ts b/backend/node/src/core/entities/validation.entity.ts index 5be9789..dd51c3c 100644 --- a/backend/node/src/core/entities/validation.entity.ts +++ b/backend/node/src/core/entities/validation.entity.ts @@ -24,6 +24,7 @@ interface ErrorSchemaError { 'id.required': string; 'id.format': string; 'user.exist': string; + 'data.id.required': string; } const errorMessage: ErrorSchemaError = { @@ -58,6 +59,7 @@ const errorMessage: ErrorSchemaError = { 'id.required': 'Validation failed (uuid v4 is expected).', 'id.format': 'Validation failed (uuid v4 is expected).', 'user.exist': 'The user is not found.', + 'data.id.required': 'The data.attributes.id property is required.', }; export function getErrorMessage(key: keyof ErrorSchemaError): string { diff --git a/backend/node/src/infrastructure/config/auth.ts b/backend/node/src/infrastructure/config/auth.ts deleted file mode 100644 index 562f9af..0000000 --- a/backend/node/src/infrastructure/config/auth.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { parse } from '@lukeed/ms'; -import type { SignerOptions } from 'fast-jwt'; - -type JwtConfig = Pick< - SignerOptions, - 'algorithm' | 'expiresIn' | 'iss' | 'aud' | 'mutatePayload' -> & { key: string }; - -interface AuthConfig { - access: JwtConfig; - refresh: JwtConfig; -} - -export const authConfig: AuthConfig = { - access: { - key: process.env.JWT_ACCESS_SECRET || 'jwtaccesssecret', - algorithm: 'HS512', - expiresIn: parse('15m') as number, - iss: process.env.JWT_ISSUER || 'jwtaccessissuer', - aud: process.env.JWT_ISSUER || 'jwtaccessissuer', - mutatePayload: true, - }, - refresh: { - key: process.env.JWT_REFRESH_SECRET || 'jwtrefreshsecret', - algorithm: 'HS512', - expiresIn: parse('30d') as number, - iss: process.env.JWT_ISSUER || 'jwtaccessissuer', - aud: process.env.JWT_ISSUER || 'jwtaccessissuer', - mutatePayload: true, - }, -}; diff --git a/backend/node/src/infrastructure/server/express/handlers/user.handler.ts b/backend/node/src/infrastructure/server/express/handlers/user.handler.ts index 34a3e67..ce71631 100644 --- a/backend/node/src/infrastructure/server/express/handlers/user.handler.ts +++ b/backend/node/src/infrastructure/server/express/handlers/user.handler.ts @@ -36,7 +36,7 @@ export async function create( req: Request>, res: Response>> ) { - let request: HttpRequestBody = { + const request: HttpRequestBody = { body: { ...req.body.data.attributes, id: req.body.data.id, @@ -46,8 +46,10 @@ export async function create( if (req.file) { const { filename, size, mimetype, path, originalname } = req.file; - request = { - ...request, + Object.assign< + HttpRequestBody, + Partial> + >(request, { file: { name: filename, size, @@ -55,7 +57,7 @@ export async function create( extension: extname(originalname), content: createReadStream(path), }, - }; + }); } const controller = createUserController(userRepository); @@ -132,7 +134,7 @@ export async function update( req: Request>, res: Response>> ) { - let request: HttpRequest = { + const request: HttpRequest = { params: req.params, body: { ...req.body.data.attributes, @@ -143,8 +145,10 @@ export async function update( if (req.file) { const { filename, size, mimetype, path, originalname } = req.file; - request = { - ...request, + Object.assign< + HttpRequest, + Partial> + >(request, { file: { name: filename, size, @@ -152,7 +156,7 @@ export async function update( extension: extname(originalname), content: createReadStream(path), }, - }; + }); } const controller = updateUserController(userRepository, fileService); @@ -168,6 +172,49 @@ export async function update( res.status(status).contentType('application/vnd.api+json').json(result); } +export async function compatUpdate( + req: Request>, + res: Response>> +) { + const request: HttpRequest = { + body: { + ...req.body.data.attributes, + id: req.body.data.id, + }, + params: { + id: req.body.data.id, + }, + }; + + if (req.file) { + const { filename, size, mimetype, path, originalname } = req.file; + + Object.assign< + HttpRequest, + Partial> + >(request, { + file: { + name: filename, + size, + type: mimetype, + extension: extname(originalname), + content: createReadStream(path), + }, + }); + } + + const controller = updateUserController(userRepository, fileService); + const { status, data } = await controller(request); + + const result = await userSerializer.serialize(data, { + linkers: { + resource: UserLinker, + }, + }); + + res.status(status).contentType('application/vnd.api+json').json(result); +} + export async function remove(req: Request, res: Response) { const controller = removeUserController(userRepository, fileService); diff --git a/backend/node/src/infrastructure/server/express/middlewares/unique-user-email.ts b/backend/node/src/infrastructure/server/express/middlewares/unique-user-email.ts index d2cf122..4d35b1a 100644 --- a/backend/node/src/infrastructure/server/express/middlewares/unique-user-email.ts +++ b/backend/node/src/infrastructure/server/express/middlewares/unique-user-email.ts @@ -17,11 +17,17 @@ export function uniqueUserEmail() { req: Request< Pick, unknown, - JsonApiData> + JsonApiData> >, _res: Response, next: NextFunction ) => { + if (!req.params?.id && req.body.data?.id) { + Object.assign(req.params, { + id: req.body.data.id, + }); + } + const httpRequest: HttpRequest< unknown, Pick, diff --git a/backend/node/src/infrastructure/server/express/routes/user.route.ts b/backend/node/src/infrastructure/server/express/routes/user.route.ts index b86d4cd..31b0e86 100644 --- a/backend/node/src/infrastructure/server/express/routes/user.route.ts +++ b/backend/node/src/infrastructure/server/express/routes/user.route.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import asyncHandler from 'express-async-handler'; import { + compatUpdate, create, findAll, findOne, @@ -11,6 +12,7 @@ import { uniqueUserEmail } from '../middlewares/unique-user-email'; import { validate } from '../middlewares/validator'; import { idParamSchema } from '../schemas/common.schema'; import { + compatUpdateUserSchema, createUserSchema, findAllUserSchema, updateUserSchema, @@ -44,6 +46,13 @@ userRouter.patch( asyncHandler(update) ); +userRouter.put( + '/', + [validate(compatUpdateUserSchema)], + uniqueUserEmail(), + asyncHandler(compatUpdate) +); + userRouter.delete( '/:id', validate(idParamSchema, 'params'), diff --git a/backend/node/src/infrastructure/server/express/schemas/user.schema.ts b/backend/node/src/infrastructure/server/express/schemas/user.schema.ts index 23c22db..dee4071 100644 --- a/backend/node/src/infrastructure/server/express/schemas/user.schema.ts +++ b/backend/node/src/infrastructure/server/express/schemas/user.schema.ts @@ -243,3 +243,24 @@ export const updateUserSchema: AllowedSchema = { dependencies: {}, }, }; + +const copiedUpdateUserSchema = structuredClone(updateUserSchema); +export const compatUpdateUserSchema: AllowedSchema = { + ...copiedUpdateUserSchema, + properties: { + ...copiedUpdateUserSchema.properties, + data: { + ...copiedUpdateUserSchema.properties?.data, + required: [ + ...(copiedUpdateUserSchema.properties?.data?.required as string[]), + 'id', + ], + errorMessage: { + required: { + type: getErrorMessage('data.type.required'), + id: getErrorMessage('data.id.required'), + }, + }, + }, + }, +}; diff --git a/openapi.yaml b/openapi.yaml index 79c381a..8c17576 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -62,6 +62,51 @@ paths: - example: links: self: "/user" + put: + tags: + - "user" + summary: Update an user (for ORY compatibility). + operationId: update-compat + requestBody: + content: + "application/vnd.api+json": + schema: + allOf: + - $ref: "#/components/schemas/UserRequestData" + - example: + links: + self: "/user" + responses: + "200": + description: Return updated user. + content: + "application/vnd.api+json": + schema: + allOf: + - $ref: "#/components/schemas/SingleUserResponseData" + - example: + links: + self: "/user" + "400": + description: Invalid request body + content: + "application/vnd.api+json": + schema: + allOf: + - $ref: "#/components/schemas/UserRequestError" + - example: + links: + self: "/user" + "500": + description: Internal server error. + content: + "application/vnd.api+json": + schema: + allOf: + - $ref: "#/components/schemas/InternalServerError" + - example: + links: + self: "/user" get: tags: - "user" @@ -261,7 +306,7 @@ components: type: string example: jsonapi: - version: "1.0" + version: "1.1" Data: type: object properties: