nestJs flexible response 만들어보기 feat swagger

김장훈·2024년 4월 13일
0

들어가기

  • API 문서화를 위해서 OpenAPI Specification(swagger) 를 많이 사용한다.
  • 그렇기에 API 를 만드는 경우 이 문서화는 필수로 해야한다(그렇지 않을 경우 무수한 질문의 요청을 ...)
  • nestjs 도 decorator 를 통해 OAS 를 지원하나 이를 위해선 response class 가 필수적이다.
  • 문제가 되는 부분은 이 문서화를 위한 response-class 가 강제화가 되며 결과적으로 1개의 API 를 만들고 이를 문서화 하기 위해선 response-class 가 무조건 필요로 하게 된다.
  • 여러개의 많은 class 가 발생하다 보니 당연히 이를 재활용하고 싶은 욕구가 생기고 결국 response 간의 커플링이 발생하는 문제 역시 생긴다.
  • 실제 문제되는 상황을 찬찬히 봐보자

문제 되는 상황

단순 메시지 전달하기

  • 데이터가 생성 되었다를 안내하기 위해서도 response 가 필요로 하다.
class SomeResponse {
  @ApiProperty({
    example: 'success',
    description: 'status of api',
  })
  status: string;
  ...
}
...

@ApiOkResponse({type:SomeResponse})
@Get('/sample')
async sample() {
  return { status: 'success' };
}

여러개 response 조합하기

  • 클라이언트에서 User 와 Setting 정보를 전달해야한다고 하는 경우 이를 swagger 에 같이 보여주기 위해선 이를 확장시킨 class 를 또 만들어줘야한다.
class UserResponse {
  @ApiProperty({
    example: 'john',
    description: 'user nickname',
  })
  nickname: string;
  constructor(nickname: string) {
    this.nickname = nickname;
  }
}

class SettingResponse {
  @ApiProperty({
    example: 'setting-name',
    description: 'setting-name',
  })
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

class SomeResponse {
  @ApiProperty({
    description: 'user',
    type: UserResponse,
  })
  user: UserResponse;

  @ApiProperty({
    description: 'user',
    type: SettingResponse,
  })
  setting: SettingResponse;
  constructor(user: UserResponse, setting: SettingResponse) {
    this.user = user;
    this.setting = setting;
  }
}

...

@ApiOkResponse({type:SomeResponse})
@Get('/sample')
async sample() {
  return new SomeResponse(...)
}
  • 위 상황에서 User 와 Account 정보를 전달해야한다고 한다면? 이를 포함하는 또 다른 class를 만들어줘야한다(SomeOtherResponse 같은)
  • 결국 내가 원하는 것은 최소한의 core response class 를 만들고 이를 활용하는 것이다.

dynamic 하게 api 를 표현해보자

  • 이를 위해선 nest js 에서 제공하는 custom decorator 를 사용하면 된다.
  • 아래 예시는 body 안에 'data'라는 key word 를 1차로 감싸는 형태이다.
  • 모든 response 가 class(=key) 단위로 사용되기를 원하므로 1차 deapth 를 추가하였다.
  • decorator name 는 다소 러프하게 작성되었다.

1. 단순 결과 값 내보내기

import { applyDecorators } from '@nestjs/common';
import { ApiOkResponse } from '@nestjs/swagger';
import { ReferenceObject, SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface';

/**
 * data 안에 key value 그대로 표현
 * response 까지 만들 필요 없을때 사용
 */
export const ApiPropertyResponse = (
  obj: SchemaObject & Partial<ReferenceObject>,
) => {
  return applyDecorators(
    ApiOkResponse({
      schema: {
        properties: {
          data: {
            ...obj,
          },
        },
      },
    }),
  );
};

...
// controller
  @ApiOperation({
    summary: 'swagger sample',
    description: 'composition of items',
  })
  @ApiPropertyResponse({
    properties: {
      status: {
        type: 'string',
        description: '성공 실패 여부, 단순 참고용',
      },
    },
  })

2. 특정 key 로 감싸기

import { Type, applyDecorators } from '@nestjs/common';
import {
  ApiExtraModels,
  ApiOkResponse,
  ApiProperty,
  getSchemaPath,
} from '@nestjs/swagger';

class ObjectResponse<T> {
  @ApiProperty({
    description: 'composition of response',
  })
  data: T;

  constructor(data: T) {
    this.data = data;
  }
}

/**
 * data 안에 1 level depth 로 response 가 표현
 * data { id: value ..  } 형태로 표현
 */
export const ApiObjectResponse = <T extends Type<unknown>>(response: T) =>
  applyDecorators(
    ApiExtraModels(ObjectResponse, response),
    ApiOkResponse({
      schema: {
        type: 'object',
        properties: {
          data: { $ref: getSchemaPath(response) },
        },
      },
    }),
  );


...

// controller
@ApiOperation({
    summary: 'swagger sample',
  })
@ApiObjectResponse(AccountResponse)

3. 특정 key 를 다이나믹 하게 사용하기

import { Type, applyDecorators } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, ApiProperty, getSchemaPath } from '@nestjs/swagger';


class ObjectResponse<T> {
  @ApiProperty({
    description: 'composition of response',
  })
  data: T;

  constructor(data: T) {
    this.data = data;
  }
}

/**
 * data 안에 여러 response 가 각자의 key 로 표현
 * data : { user : { id: value .. }, setting: { id: value .. }, settings: [{id:value}] } 형태로 표현
 */
export const ApiCompositedObjectResponse = <
  T extends Record<string, Type<unknown> | Type<unknown>[]>,
>(
  responses: T,
) => {
  const properties: Record<string, any> = {};
  const extraModels: Function[] = []; // Store extra models separately
  for (const [key, response] of Object.entries(responses)) {
    if (Array.isArray(response)) {
      properties[key] = {
        type: 'array',
        items: {
          $ref: getSchemaPath(response[0]),
        },
      };
      extraModels.push(...response); // Add all models in the array to extraModels
    } else {
      properties[key] = { $ref: getSchemaPath(response as Type<unknown>) };
      extraModels.push(response);
    }
  }
  return applyDecorators(
    ApiExtraModels(ObjectResponse, ...extraModels), // Spread extraModels here
    ApiOkResponse({
      schema: {
        type: 'object',
        properties: {
          data: {
            type: 'object',
            properties,
          },
        },
      },
    }),
  );
};

...
// controller
  @ApiOperation({
    summary: 'swagger sample',
    description: 'composition of items',
  })
  @ApiCompositedObjectResponse({
    user: UserResponse,
    setting: SettingResponse,
    settings: [SettingResponse],
  })

4. array 로 전달하기

4.1. 1개의 response 가 array 형태인 경우


import { Type, applyDecorators } from '@nestjs/common';
import {
  ApiExtraModels,
  ApiOkResponse,
  ApiProperty,
  getSchemaPath,
} from '@nestjs/swagger';

export class ArrayResponse<T> {
  @ApiProperty({
    description: 'Array of responses',
    isArray: true,
  })
  data: T[];

  constructor(data: T[]) {
    this.data = data;
  }
}

/**
 * ApiObjectResponse 를 array 로 표현
 */
export const ApiArrayResponse = <T extends Type<unknown>>(response: T) =>
  applyDecorators(
    ApiExtraModels(ArrayResponse, response),
    ApiOkResponse({
      schema: {
        allOf: [
          { $ref: getSchemaPath(ArrayResponse) },
          {
            properties: {
              data: {
                type: 'array',
                items: { $ref: getSchemaPath(response) },
              },
            },
          },
        ],
      },
    }),
  );
...
// controller
  @ApiOperation({
    summary: 'swagger sample',
    description: 'composition of items',
  })
  @ApiArrayResponse(UserResponse)

4.2. 여러개의 response 를 1개의 array 로 표현해야하는 경우

  • [user, setting ... ]
import { Type, applyDecorators } from '@nestjs/common';
import {
  ApiExtraModels,
  ApiOkResponse,
  ApiProperty,
  getSchemaPath,
} from '@nestjs/swagger';

export class ArrayResponse<T> {
  @ApiProperty({
    description: 'Array of responses',
    isArray: true,
  })
  data: T[];

  constructor(data: T[]) {
    this.data = data;
  }
}

/**
 * ApiCompositedObjectResponse 를 array 로 표현
 */
export const ApiArrayCompositedObjectResponse = <
  T extends Record<string, Type<unknown>>,
>(
  responses: T,
) => {
  const responseValues = Object.values(responses);

  return applyDecorators(
    ApiExtraModels(ArrayResponse, ...responseValues),
    ApiOkResponse({
      schema: {
        allOf: [
          { $ref: getSchemaPath(ArrayResponse) },
          {
            properties: {
              data: {
                type: 'array',
                items: {
                  type: 'object',
                  properties: Object.fromEntries(
                    Object.entries(responses).map(([key, response], index) => [
                      key,
                      { $ref: getSchemaPath(response) },
                    ]),
                  ),
                },
              },
            },
          },
        ],
      },
    }),
  );
};

...
// controller
  @ApiOperation({
    summary: 'swagger sample',
  })
  @ApiArrayCompositedObjectResponse({
    user: UserResponse,
    setting: SettingResponse,
  })

결론

  • 이를 통해서 우리는 정말 core 한 형태의 class 만 작성하면 되고 새로운 api 문서화를 위한 class 작업 및 조합 작업들을 할 필요가 없어졌다.
  • 진작 공부좀 할껄 ..
profile
읽기 좋은 code란 무엇인가 고민하는 백엔드 개발자 입니다.

0개의 댓글