Skip to content

Commit

Permalink
feat: 히스토리 api ts rest 적용 (#599)
Browse files Browse the repository at this point in the history
* feat: histories 라우터에 추가

histories 라우터에 추가

* feat: histories controller 추가

histories controller 추가

* feat: impl 추가

impl 추가

* feat: histories 전용 type 파일 추가

histories 전용 type 파일 추가

* feat: histories service 추가

histories service 추가

* feat: 권한 없음 에러 추가

권한 없음 에러 추가

* feat: 권한 없음 에러 오브젝트 추가

권한 없음 에러 오브젝트 추가

* feat: meta 스키마 추가

meta 스키마 추가

* style: eslint comma 추가

eslint comma 추가

* feat: contract에 histories 추가

contract에 histories 추가

* feat: contract에 histories 명세 추가

contract에 histories 명세 추가

* feat: contract에 histories에서 사용하는 query string 스키마 추가

contract에 histories에서 사용하는 GET 메서드들의 query string 스키마 추가

* feat: contract에 공용으로 사용할 권한 없음 에러 스키마 추가

contract에 공용으로 사용할 권한 없음 에러 스키마 추가

* feat(histories): histories controller 추가

- 전체 대출 기록 조회하는 controller 추가
- 나의 대출 기록 조회하는 controller 추가

* feat(histories): histories service 추가

- 전체 대출 기록 조회하는 service 추가
- 나의 대출 기록 조회하는 service 추가

* feat(histories): histories 조회 시의 query string schema 변경

histories 조회 시의 query string schema 구조 변경

* feat(histories): 라우터 분기 자세하게 나눔

- histories의 라우터 분기를 '나의 기록 조회', '전체 기록 조회'로 나눔

* feat(histories): histories service index.ts 추가

histories service index.ts 추가

* fix: 쿼리 파라미터를 json으로 파싱

참고:
https://ts-rest.com/docs/core/#query-parameters
https://ts-rest.com/docs/express/#options

* feat(histories): 결과 반환 시, literal이 아닌 아이템 리스트 반환

결과 반환 시, z.literal 조회 성공 문구 대신 데이터베이스 조회한 결과 반환하게 함

* style(histories): import 형식 변경

VHistories 가져올 때 중괄호 이용

* feat(histories): histories 조회 결과 200 시, 스키마 추가

histories를 조회하여 200 스테이터스 일 때 반환하는 스키마 추가

* feat(histories): 검색 조건 callsign 추가

callsign으로도 검색할 수 있도록 조건 추가 및 query undefined 체크 추가

* feat(histories): meta 스키마 추가

meta 스키마 공용 스키마로 추가

* feat(histories): id 타입 변경

id 타입을 string -> number로 변경

* feat(histories): meta 스키마 positiveInt로 변경

z.number()...를 positiveInt로 변경

* feat(histories): 서비스 함수 반환 타입 변경

서비스 함수 반환 타입 오브젝트 형식으로 변경

* feat(histories): getMyHistories 반환값 변경

getMyHistories 반환값 데이터베이스 조회 결과로 변경

* feat(histories): 서비스 함수 반환 타입 변경

서비스 함수들 반환 타입 변경

* chore(histories): 서비스 파일 분리 및 컨트롤러 파일 분리

서비스 파일과 서비스 구현 파일, 컨트롤러 파일과 컨트롤러 구현 파일 분리

* fix: 스키마에서 date와 string 모두 허용

* fix: 패턴 매칭에서 타입이 좁혀지지 않던 문제

* fix: users 경로 숨김

* fix: stocks 타입 오류 수정

---------

Co-authored-by: scarf <greenscarf005@gmail.com>
  • Loading branch information
nyj001012 and scarf005 authored Aug 13, 2023
1 parent 2e4b534 commit 27b3d11
Show file tree
Hide file tree
Showing 19 changed files with 291 additions and 15 deletions.
1 change: 1 addition & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ app.use('/api', router);
createExpressEndpoints(contract, routerV2, app, {
logInitialization: true,
responseValidation: true,
jsonQuery: true,
});

// 에러 핸들러
Expand Down
2 changes: 1 addition & 1 deletion backend/src/entity/entities/VHistories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { DataSource, ViewColumn, ViewEntity } from 'typeorm';
})
export class VHistories {
@ViewColumn()
id: string;
id: number;

@ViewColumn()
lendingCondition: string;
Expand Down
30 changes: 30 additions & 0 deletions backend/src/v2/histories/controller/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { contract } from '@jiphyeonjeon-42/contracts';
import { P, match } from 'ts-pattern';
import {
UnauthorizedError,
HandlerFor,
unauthorized,
} from '../../shared';
import { HistoriesService } from '../service';

// mkGetMyHistories
type GetMyDeps = Pick<HistoriesService, 'searchMyHistories'>;
type MkGetMy = (services: GetMyDeps) => HandlerFor<typeof contract.histories.getMyHistories>;
export const mkGetMyHistories: MkGetMy = ({ searchMyHistories }) =>
async ({ query }) => {
const result = await searchMyHistories(query);

return match(result)
.with(P.instanceOf(UnauthorizedError), () => unauthorized)
.otherwise((body) => ({ status: 200, body } as const));
};

// mkGetAllHistories
type GetAllDeps = Pick<HistoriesService, 'searchAllHistories'>;
type MkGetAll = (services: GetAllDeps) => HandlerFor<typeof contract.histories.getAllHistories>;
export const mkGetAllHistories: MkGetAll = ({ searchAllHistories }) => async ({ query }) => {
const result = await searchAllHistories(query);
return match(result)
.with(P.instanceOf(UnauthorizedError), () => unauthorized)
.otherwise((body) => ({ status: 200, body } as const));
};
9 changes: 9 additions & 0 deletions backend/src/v2/histories/controller/impl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { mkGetMyHistories, mkGetAllHistories } from './controller';
import {
HistoriesService,
} from '../service';

export const implHistoriesController = (service: HistoriesService) => ({
getMyHistories: mkGetMyHistories(service),
getAllHistories: mkGetAllHistories(service),
});
1 change: 1 addition & 0 deletions backend/src/v2/histories/controller/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './controller';
26 changes: 26 additions & 0 deletions backend/src/v2/histories/impl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { contract } from '@jiphyeonjeon-42/contracts';
import { initServer } from '@ts-rest/express';
import jipDataSource from '~/app-data-source';
import { roleSet } from '~/v1/auth/auth.type';
import authValidate from '~/v1/auth/auth.validate';
import { VHistories } from '~/entity/entities/VHistories';
import { implHistoriesService } from '~/v2/histories/service/impl';
import { implHistoriesController } from '~/v2/histories/controller/impl';

const service = implHistoriesService({
historiesRepo: jipDataSource.getRepository(VHistories),
});

const handler = implHistoriesController(service);

const s = initServer();
export const histories = s.router(contract.histories, {
getMyHistories: {
middleware: [authValidate(roleSet.all)],
handler: handler.getMyHistories,
},
getAllHistories: {
middleware: [authValidate(roleSet.librarian)],
handler: handler.getAllHistories,
},
});
13 changes: 13 additions & 0 deletions backend/src/v2/histories/service/impl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Repository } from 'typeorm';
import { VHistories } from '~/entity/entities/VHistories';

import {
mkSearchHistories,
} from './service';

export const implHistoriesService = (repos: {
historiesRepo: Repository<VHistories>;
}) => ({
searchMyHistories: mkSearchHistories(repos),
searchAllHistories: mkSearchHistories(repos),
});
20 changes: 20 additions & 0 deletions backend/src/v2/histories/service/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { VHistories } from '~/entity/entities/VHistories';
import { Meta, UnauthorizedError } from '~/v2/shared';

type Args = {
query?: string | undefined;
page: number;
limit: number;
type?: 'title' | 'user' | 'callsign' | undefined;
};

export type HistoriesService = {
searchMyHistories: (
args: Args,
) => Promise<UnauthorizedError | { items: VHistories[], meta: Meta }>;
searchAllHistories: (
args: Args,
) => Promise<UnauthorizedError | { items: VHistories[], meta: Meta }>;
}

export * from './service';
68 changes: 68 additions & 0 deletions backend/src/v2/histories/service/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/* eslint-disable import/no-extraneous-dependencies */
import { match } from 'ts-pattern';

import { FindOperator, Like, type Repository } from 'typeorm';
import { VHistories } from '~/entity/entities/VHistories';

import { Meta } from '~/v2/shared';
import type { HistoriesService } from '.';

// HistoriesService

type Repos = { historiesRepo: Repository<VHistories> };

type MkSearchHistories = (
repos: Repos
) => HistoriesService['searchAllHistories'];

type whereCondition = {
login: FindOperator<string>,
title: FindOperator<string>,
callSign: FindOperator<string>,
} | [
{ login: FindOperator<string> },
{ title: FindOperator<string> },
{ callSign: FindOperator<string> },
];

export const mkSearchHistories: MkSearchHistories = ({ historiesRepo }) => async ({
query, type, page, limit,
}): Promise<{ items: VHistories[], meta: Meta }> => {
let filterQuery: whereCondition = {
login: Like('%%'),
title: Like('%%'),
callSign: Like('%%'),
};
if (query !== undefined) {
if (type === 'user') {
filterQuery.login = Like(`%${query}%`);
} else if (type === 'title') {
filterQuery.title = Like(`%${query}%`);
} else if (type === 'callsign') {
filterQuery.callSign = Like(`%${query}%`);
} else {
filterQuery = [
{ login: Like(`%${query}%`) },
{ title: Like(`%${query}%`) },
{ callSign: Like(`%${query}%`) },
];
}
}
const [items, count] = await historiesRepo.findAndCount({
where: filterQuery,
take: limit,
skip: limit * page,
});
const meta: Meta = {
totalItems: count,
itemCount: items.length,
itemsPerPage: limit,
totalPages: Math.ceil(count / limit),
currentPage: page + 1,
};
const returnObject = {
items,
meta,
};
return returnObject;
};
16 changes: 16 additions & 0 deletions backend/src/v2/histories/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { z } from 'zod';
import { positiveInt } from '~/v1/reviews/controller/reviews.type';

export type ParsedHistoriesSearchCondition = z.infer<typeof getHistoriesSearchCondition>;
export const getHistoriesSearchCondition = z.object({
query: z.string().optional(),
type: z.enum(['user', 'title', 'callsign']).optional(),
page: z.number().nonnegative().default(0),
limit: z.number().nonnegative().default(10),
});

export type ParsedHistoriesUserInfo = z.infer<typeof getHistoriesUserInfo>;
export const getHistoriesUserInfo = z.object({
userId: positiveInt,
userRole: positiveInt.max(3),
});
2 changes: 2 additions & 0 deletions backend/src/v2/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { contract } from '@jiphyeonjeon-42/contracts';
import { initServer } from '@ts-rest/express';

import { reviews } from './reviews/impl';
import { histories } from './histories/impl';
import { stock } from './stock/impl';

const s = initServer();
export default s.router(contract, {
reviews,
histories,
stock,
});
8 changes: 8 additions & 0 deletions backend/src/v2/shared/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ export class BookInfoNotFoundError extends Error {
}
}

export class UnauthorizedError extends Error {
declare readonly _tag: 'UnauthorizedError';

constructor() {
super('권한이 없습니다');
}
}

export class BookNotFoundError extends Error {
declare readonly _tag: 'BookNotFoundError';

Expand Down
10 changes: 9 additions & 1 deletion backend/src/v2/shared/responses.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { bookNotFoundSchema, bookInfoNotFoundSchema, reviewNotFoundSchema } from '@jiphyeonjeon-42/contracts';
import { bookInfoNotFoundSchema, reviewNotFoundSchema, unauthorizedSchema, bookNotFoundSchema } from '@jiphyeonjeon-42/contracts';
import { z } from 'zod';

export const reviewNotFound = {
Expand All @@ -17,6 +17,14 @@ export const bookInfoNotFound = {
} as z.infer<typeof bookInfoNotFoundSchema>,
} as const;

export const unauthorized = {
status: 401,
body: {
code: 'UNAUTHORIZED',
description: '권한이 없습니다.',
} as z.infer<typeof unauthorizedSchema>,
} as const;

export const bookNotFound = {
status: 404,
body: {
Expand Down
11 changes: 3 additions & 8 deletions backend/src/v2/stock/controller/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,10 @@ import { StockService } from '../service';
type GetDeps = Pick<StockService, 'searchStock'>;
type MkGet = (services: GetDeps) => HandlerFor<typeof contract.stock.get>;
export const mkGetStock: MkGet = ({ searchStock }) =>
async ({ query: { page, limit } }) => {
contract.stock.get.query.safeParse({ page, limit });
const result = await searchStock({ page, limit });
async ({ query }) => {
const result = await searchStock(query);

return match(result)
.otherwise(() => ({
status: 200,
body: result,
} as const));
return { status: 200, body: result } as const;
};

type PatchDeps = Pick<StockService, 'updateStock'>;
Expand Down
37 changes: 37 additions & 0 deletions contracts/src/histories/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { initContract } from '@ts-rest/core';
import { z } from 'zod';
import {
historiesGetQuerySchema, historiesGetResponseSchema,
} from './schema';
import { unauthorizedSchema } from '../shared';

export * from './schema';

// contract 를 생성할 때, router 함수를 사용하여 api 를 생성
const c = initContract();

export const historiesContract = c.router(
{
getMyHistories: {
method: 'GET',
path: '/mypage/histories',
description: '마이페이지에서 본인의 대출 기록을 가져온다.',
query: historiesGetQuerySchema,
responses: {
200: historiesGetResponseSchema,
401: unauthorizedSchema,
},
},
getAllHistories: {
method: 'GET',
path: '/histories',
description: '사서가 전체 대출 기록을 가져온다.',
query: historiesGetQuerySchema,
responses: {
200: historiesGetResponseSchema,
401: unauthorizedSchema,
},
},

},
);
32 changes: 32 additions & 0 deletions contracts/src/histories/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { dateLike, metaSchema, positiveInt } from '../shared';
import { z } from '../zodWithOpenapi';

export const historiesGetQuerySchema = z.object({
query: z.string().optional(),
type: z.enum(['user', 'title', 'callsign']).optional(),
page: z.number().int().nonnegative().default(0),
limit: z.number().int().nonnegative().default(10),
});

export const historiesGetResponseSchema = z.object({
items: z.array(
z.object({
id: positiveInt,
lendingCondition: z.string(),
login: z.string(),
returningCondition: z.string(),
penaltyDays: z.number().int().nonnegative(),
callSign: z.string(),
title: z.string(),
bookInfoId: positiveInt,
image: z.string(),
createdAt: dateLike,
returnedAt: dateLike,
updatedAt: dateLike,
dueDate: dateLike,
lendingLibrarianNickName: z.string(),
returningLibrarianNickname: z.string(),
}),
),
meta: metaSchema,
});
6 changes: 5 additions & 1 deletion contracts/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { initContract } from '@ts-rest/core';
import { reviewsContract } from './reviews';
import { historiesContract } from './histories';
import { usersContract } from './users';
import { likesContract } from './likes';
import { stockContract } from './stock';
Expand All @@ -14,8 +15,11 @@ export const contract = c.router(
{
// likes: likesContract,
reviews: reviewsContract,
histories: historiesContract,

stock: stockContract,
users: usersContract,
// TODO(@scarf005): 유저 서비스 작성
// users: usersContract,
},
{
pathPrefix: '/api/v2',
Expand Down
6 changes: 6 additions & 0 deletions contracts/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { z } from './zodWithOpenapi';

export const positiveInt = z.coerce.number().int().nonnegative();

export const dateLike = z.union([z.date(), z.string()]).transform(String)

export const bookInfoIdSchema = positiveInt.describe('개별 도서 ID');

type ErrorMessage = { code: string; description: string };
Expand All @@ -22,6 +24,10 @@ type ErrorMessage = { code: string; description: string };
export const mkErrorMessageSchema = <const T extends string>(code: T) =>
z.object({ code: z.literal(code) as z.ZodLiteral<T> });

export const unauthorizedSchema = mkErrorMessageSchema('UNAUTHORIZED').describe(
'권한이 없습니다.',
);

export const bookNotFoundSchema =
mkErrorMessageSchema('BOOK_NOT_FOUND').describe('해당 도서가 존재하지 않습니다');

Expand Down
Loading

0 comments on commit 27b3d11

Please sign in to comment.