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

feat: 히스토리 api ts rest 적용 #599

Merged
merged 35 commits into from
Aug 13, 2023
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
6bc0872
feat: histories 라우터에 추가
nyj001012 Jul 29, 2023
642c3ae
feat: histories controller 추가
nyj001012 Jul 29, 2023
0123076
feat: impl 추가
nyj001012 Jul 29, 2023
ae75767
feat: histories 전용 type 파일 추가
nyj001012 Jul 29, 2023
a03eb43
feat: histories service 추가
nyj001012 Jul 29, 2023
88a9052
feat: 권한 없음 에러 추가
nyj001012 Jul 29, 2023
3153397
feat: 권한 없음 에러 오브젝트 추가
nyj001012 Jul 29, 2023
99b5742
feat: meta 스키마 추가
nyj001012 Jul 29, 2023
e4d6bae
style: eslint comma 추가
nyj001012 Aug 1, 2023
b29e417
feat: contract에 histories 추가
nyj001012 Aug 1, 2023
4457518
feat: contract에 histories 명세 추가
nyj001012 Aug 1, 2023
399233b
feat: contract에 histories에서 사용하는 query string 스키마 추가
nyj001012 Aug 1, 2023
7c4ad3c
feat: contract에 공용으로 사용할 권한 없음 에러 스키마 추가
nyj001012 Aug 1, 2023
ba76a8d
feat(histories): histories controller 추가
nyj001012 Aug 1, 2023
37080d6
feat(histories): histories service 추가
nyj001012 Aug 1, 2023
3df6b8d
feat(histories): histories 조회 시의 query string schema 변경
nyj001012 Aug 1, 2023
dd774fe
feat(histories): 라우터 분기 자세하게 나눔
nyj001012 Aug 1, 2023
92d72de
feat(histories): histories service index.ts 추가
nyj001012 Aug 2, 2023
ec427df
fix: 쿼리 파라미터를 json으로 파싱
scarf005 Aug 2, 2023
96894cb
feat(histories): 결과 반환 시, literal이 아닌 아이템 리스트 반환
nyj001012 Aug 7, 2023
1d956ef
style(histories): import 형식 변경
nyj001012 Aug 7, 2023
ebc2df1
feat(histories): histories 조회 결과 200 시, 스키마 추가
nyj001012 Aug 7, 2023
2876193
feat(histories): 검색 조건 callsign 추가
nyj001012 Aug 7, 2023
a90eb0c
feat(histories): meta 스키마 추가
nyj001012 Aug 7, 2023
b81689c
feat(histories): id 타입 변경
nyj001012 Aug 7, 2023
8356875
feat(histories): meta 스키마 positiveInt로 변경
nyj001012 Aug 7, 2023
4636b8a
feat(histories): 서비스 함수 반환 타입 변경
nyj001012 Aug 7, 2023
a91fea2
feat(histories): getMyHistories 반환값 변경
nyj001012 Aug 7, 2023
9c5b560
feat(histories): 서비스 함수 반환 타입 변경
nyj001012 Aug 7, 2023
c2ed994
chore(histories): 서비스 파일 분리 및 컨트롤러 파일 분리
nyj001012 Aug 7, 2023
840747e
Merge branch 'develop' into 598-히스토리-api-ts-rest-적용
scarf005 Aug 13, 2023
eec4ade
fix: 스키마에서 date와 string 모두 허용
scarf005 Aug 13, 2023
2aa991f
fix: 패턴 매칭에서 타입이 좁혀지지 않던 문제
scarf005 Aug 13, 2023
d32ded4
fix: users 경로 숨김
scarf005 Aug 13, 2023
12c264b
fix: stocks 타입 오류 수정
scarf005 Aug 13, 2023
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
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
50 changes: 50 additions & 0 deletions backend/src/v2/histories/controller/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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: {
query, page, limit, type,
},
}) => {
contract.histories.getMyHistories.query.safeParse({
query, page, limit, type,
});
const result = await searchMyHistories({
query, page, limit, type,
});

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

// mkGetAllHistories
type GetAllDeps = Pick<HistoriesService, 'searchAllHistories'>;
type MkGetAll = (services: GetAllDeps) => HandlerFor<typeof contract.histories.getAllHistories>;
export const mkGetAllHistories: MkGetAll = ({ searchAllHistories }) => async ({
query: { query, page, limit, type },
}) => {
const parsedQuery = contract.histories.getMyHistories.query.parse({
query, page, limit, type,
});
const result = await searchAllHistories(parsedQuery);
return match(result)
.with(P.instanceOf(UnauthorizedError), () => unauthorized)
.otherwise(() => ({
status: 200,
body: result,
} as const));
Copy link
Member

@scarf005 scarf005 Aug 7, 2023

Choose a reason for hiding this comment

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

image

반환값이 UnauthorizedError | { items: [...], meta: { ... } } 타입이기 때문에, UnauthorizedError를 예상하지 못한 ts-rest에서 오류를 내고 있습니다. 다음을 참고하시면 타입을 좁힐 때 도움이 될 것 같습니다.

https://github.com/gvergnaud/ts-pattern#withpattern-handler
https://github.com/gvergnaud/ts-pattern#exhaustive-otherwise-and-run

};
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,8 +3,10 @@ import { contract } from '@jiphyeonjeon-42/contracts';
import { initServer } from '@ts-rest/express';

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

const s = initServer();
export default s.router(contract, {
reviews,
histories,
});
8 changes: 8 additions & 0 deletions backend/src/v2/shared/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,11 @@ export class BookInfoNotFoundError extends Error {
super(`개별 도서 정보 (id: ${bookInfoId})를 찾을 수 없습니다`);
}
}

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

constructor() {
super('권한이 없습니다');
}
}
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 { bookInfoNotFoundSchema, reviewNotFoundSchema } from '@jiphyeonjeon-42/contracts';
import { bookInfoNotFoundSchema, reviewNotFoundSchema, unauthorizedSchema } from '@jiphyeonjeon-42/contracts';
import { z } from 'zod';

export const reviewNotFound = {
Expand All @@ -16,3 +16,11 @@ export const bookInfoNotFound = {
description: '검색한 책이 존재하지 않습니다.',
} as z.infer<typeof bookInfoNotFoundSchema>,
} as const;

export const unauthorized = {
status: 401,
body: {
code: 'UNAUTHORIZED',
description: '권한이 없습니다.',
} as z.infer<typeof unauthorizedSchema>,
} as const;
10 changes: 10 additions & 0 deletions backend/src/v2/shared/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
import { AppRoute } from '@ts-rest/core';
import { AppRouteOptions } from '@ts-rest/express';
import { z } from 'zod';

export type HandlerFor<T extends AppRoute> = AppRouteOptions<T>['handler'];

export type Meta = z.infer<typeof meta>;
export const meta = z.object({
totalItems: z.number().nonnegative(),
nyj001012 marked this conversation as resolved.
Show resolved Hide resolved
itemCount: z.number().nonnegative(),
itemsPerPage: z.number().nonnegative(),
totalPages: z.number().nonnegative(),
currentPage: z.number().nonnegative(),
});
nyj001012 marked this conversation as resolved.
Show resolved Hide resolved
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 { 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: z.string(),
returnedAt: z.string(),
updatedAt: z.date(),
Copy link
Member

Choose a reason for hiding this comment

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

VHistoriescreatedAt 필드의 타입은 Date이나, createdAt의 타입이 string이어서 타입 오류가 나고 있습니다. 하지만 typeorm 쿼리에서 날짜를 문자열 형식으로 반환하기 때문에, 이와 같은 경우에는 coerce를 사용하는 것을 추천드립니다.

colinhacks/zod#879

dueDate: z.string(),
lendingLibrarianNickName: z.string(),
returningLibrarianNickname: z.string(),
}),
),
meta: metaSchema,
});
3 changes: 3 additions & 0 deletions 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 { likesContract } from './likes';

export * from './reviews';
Expand All @@ -12,6 +13,8 @@ export const contract = c.router(
{
// likes: likesContract,
reviews: reviewsContract,
histories: historiesContract,

},
{
pathPrefix: '/api/v2',
Expand Down
12 changes: 12 additions & 0 deletions contracts/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,15 @@ export const mkErrorMessageSchema = <const T extends string>(code: T) =>

export const bookInfoNotFoundSchema =
mkErrorMessageSchema('BOOK_INFO_NOT_FOUND').describe('해당 도서 연관 정보가 존재하지 않습니다');

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

export const metaSchema = z.object({
totalItems: positiveInt,
itemCount: positiveInt,
itemsPerPage: positiveInt,
totalPages: positiveInt,
currentPage: positiveInt,
});