Never give up

API Generator (feat. swagger-typescript-api) 본문

해왔던 삽질..

API Generator (feat. swagger-typescript-api)

대기만성 개발자 2024. 3. 17. 15:00
반응형

최근 회사에서 swagger에 있는 api 및 타입 자동생성 도입을 고민해서

 

두가지를 시도해봤습니다

 

1. OpenAPI Generator

https://www.npmjs.com/package/@openapitools/openapi-generator-cli

 

2. swagger-typescript-api

https://www.npmjs.com/package/swagger-typescript-api

 

작동방식은 둘 다 swagger에 있는 yaml이나 json파일을 파싱해서 코드를 생성해주는 도구인데

 

1번은 java로 만들어져있어서 별도로 jdk를 설치해야돼고 상대적으로 무거웠습니다

 

다만 config파일을 직접 설정할 수 있는면에서 script 부분이 깔끔해지는 장점이 있습니다

 

이런식으로 말이죠

// package.json
"scripts": {
    "open-api": "openapi-generator-cli -i -c ./openapi.json"
}

// openapi.json
{
  "modelPackage": "./service/model",
  "apiPackage": "./service/api",
  "inputSpec": "https://petstore.swagger.io/v2/swagger.json",
  "generatorName": "typescript-axios",
  "withSeparateModelsAndApi": true
}

 

2번은 별도의 config파일을 설정하는 옵션이 없어서 script부분이 조금 지저분해지는 단점이 있었습니다

"swagger-api": "swagger-typescript-api -p https://petstore.swagger.io/v2/swagger.json -o ./test --modular --axios --silent"

(참 보기 싫죠..?)

 

추가로 1번은 동일한 타입도 다른이름으로 새로 생성하는 반면

 

2번은 동일 타입은 type a = b 이런식으로 사용하는것도 봐서

 

상대적으로 트랜스파일링 하는데 시간이 덜 소요될거로 예상됩니다

 

서론이 참 길었는데.. 생성된 파일을 직접 한번 보겠습니다

 

(사용된 예제 swagger link: https://petstore.swagger.io/)

 

< 페이지 상단부분 >

큰 제목 아래에 .json파일에 들어가서 링크를 복사하고

 

script에 path를 지정해줍니다

"-p https://petstore.swagger.io/v2/swagger.json"

 

그리고 원하는 파일 형태, 파일 위치 등을 지정해줍니다

"-o ./test --modular --axios --silent"

해당 옵션들은 npm 링크에 잘 나와있으니 참고하시면 되겠습니다

 

위 두개를 다 넣었을 때 다음과 같이 돼고

"swagger-api": "swagger-typescript-api -p https://petstore.swagger.io/v2/swagger.json -o ./test --modular --axios --silent"

yarn이나 npm같은 패키지 매니저를 통해 swagger-api를 입력하면

 

다음과 같이 생성 됩니다

< 생성된 파일 >

먼저 데이터 부터 보면

export interface ApiResponse {
  /** @format int32 */
  code?: number;
  type?: string;
  message?: string;
}

export interface Category {
  /** @format int64 */
  id?: number;
  name?: string;
}

export interface Pet {
  /** @format​_ int64 */
  id?: number;
  category?: Category;
  /** @example "doggie" */
  name: string;
  photoUrls: string[];
  tags?: Tag[];
  /** pet status in the store */
  status?: "available" | "pending" | "sold";
}
// -- 이하 생략

생각보다 type은 깔끔하게 나오는것을 확인할 수 있고

 

client와 생성된 api를 보면 그대로 쓰기에는 조금 과하다 싶은 코드가 보입니다

 

http-client.ts

import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, HeadersDefaults, ResponseType } from "axios";
import axios from "axios";

export type QueryParamsType = Record<string | number, any>;

export interface FullRequestParams extends Omit<AxiosRequestConfig, "data" | "params" | "url" | "responseType"> {
  /** set parameter to `true` for call `securityWorker` for this request */
  secure?: boolean;
  /** request path */
  path: string;
  /** content type of request body */
  type?: ContentType;
  /** query params */
  query?: QueryParamsType;
  /** format of response (i.e. response.json() -> format: "json") */
  format?: ResponseType;
  /** request body */
  body?: unknown;
}

export type RequestParams = Omit<FullRequestParams, "body" | "method" | "query" | "path">;

export interface ApiConfig<SecurityDataType = unknown> extends Omit<AxiosRequestConfig, "data" | "cancelToken"> {
  securityWorker?: (
    securityData: SecurityDataType | null,
  ) => Promise<AxiosRequestConfig | void> | AxiosRequestConfig | void;
  secure?: boolean;
  format?: ResponseType;
}

export enum ContentType {
  Json = "application/json",
  FormData = "multipart/form-data",
  UrlEncoded = "application/x-www-form-urlencoded",
  Text = "text/plain",
}

export class HttpClient<SecurityDataType = unknown> {
  public instance: AxiosInstance;
  private securityData: SecurityDataType | null = null;
  private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
  private secure?: boolean;
  private format?: ResponseType;

  constructor({ securityWorker, secure, format, ...axiosConfig }: ApiConfig<SecurityDataType> = {}) {
    this.instance = axios.create({ ...axiosConfig, baseURL: axiosConfig.baseURL || "https://petstore.swagger.io/v2" });
    this.secure = secure;
    this.format = format;
    this.securityWorker = securityWorker;
  }

  public setSecurityData = (data: SecurityDataType | null) => {
    this.securityData = data;
  };

  protected mergeRequestParams(params1: AxiosRequestConfig, params2?: AxiosRequestConfig): AxiosRequestConfig {
    const method = params1.method || (params2 && params2.method);

    return {
      ...this.instance.defaults,
      ...params1,
      ...(params2 || {}),
      headers: {
        ...((method && this.instance.defaults.headers[method.toLowerCase() as keyof HeadersDefaults]) || {}),
        ...(params1.headers || {}),
        ...((params2 && params2.headers) || {}),
      },
    };
  }

  protected stringifyFormItem(formItem: unknown) {
    if (typeof formItem === "object" && formItem !== null) {
      return JSON.stringify(formItem);
    } else {
      return `${formItem}`;
    }
  }

  protected createFormData(input: Record<string, unknown>): FormData {
    return Object.keys(input || {}).reduce((formData, key) => {
      const property = input[key];
      const propertyContent: any[] = property instanceof Array ? property : [property];

      for (const formItem of propertyContent) {
        const isFileType = formItem instanceof Blob || formItem instanceof File;
        formData.append(key, isFileType ? formItem : this.stringifyFormItem(formItem));
      }

      return formData;
    }, new FormData());
  }

  public request = async <T = any, _E = any>({
    secure,
    path,
    type,
    query,
    format,
    body,
    ...params
  }: FullRequestParams): Promise<AxiosResponse<T>> => {
    const secureParams =
      ((typeof secure === "boolean" ? secure : this.secure) &&
        this.securityWorker &&
        (await this.securityWorker(this.securityData))) ||
      {};
    const requestParams = this.mergeRequestParams(params, secureParams);
    const responseFormat = format || this.format || undefined;

    if (type === ContentType.FormData && body && body !== null && typeof body === "object") {
      body = this.createFormData(body as Record<string, unknown>);
    }

    if (type === ContentType.Text && body && body !== null && typeof body !== "string") {
      body = JSON.stringify(body);
    }

    return this.instance.request({
      ...requestParams,
      headers: {
        ...(requestParams.headers || {}),
        ...(type && type !== ContentType.FormData ? { "Content-Type": type } : {}),
      },
      params: query,
      responseType: responseFormat,
      data: body,
      url: path,
    });
  };
}

 

pet.ts

import { ApiResponse, Pet } from "./data-contracts";
import { ContentType, HttpClient, RequestParams } from "./http-client";

export class Pet<SecurityDataType = unknown> extends HttpClient<SecurityDataType> {
  /**
   * No description
   *
   * @tags pet
   * @name UploadFile
   * @summary uploads an image
   * @request POST:/pet/{petId}/uploadImage
   * @secure
   */
  uploadFile = (
    petId: number,
    data: {
      /** Additional data to pass to server */
      additionalMetadata?: string;
      /** file to upload */
      file?: File;
    },
    params: RequestParams = {},
  ) =>
    this.request<ApiResponse, any>({
      path: `/pet/${petId}/uploadImage`,
      method: "POST",
      body: data,
      secure: true,
      type: ContentType.FormData,
      format: "json",
      ...params,
    });
  /**
   * No description
   *
   * @tags pet
   * @name AddPet
   * @summary Add a new pet to the store
   * @request POST:/pet
   * @secure
   */
  addPet = (body: Pet, params: RequestParams = {}) =>
    this.request<any, void>({
      path: `/pet`,
      method: "POST",
      body: body,
      secure: true,
      type: ContentType.Json,
      ...params,
    });
    //- 이하 생략
}

 

 

이 외에도 실제로 적용했을 때, url이 보통 /api/v1/blah일 텐데

 

v1Blah 이런식으로 나와서 사용하기에는 조금 애매한 부분이 있었습니다

 

사실 data쪽도 비슷한 이름으로 파싱되는것을 보고 이걸 도입해야될지 말아야될지 고민이 많이 필요했던거 같습니다

(사실 이정도면 안쓰는게 이득일지도...)

 

이직기간이 길어져서 한동안 포스트를 못했는데, 다시 열심히 포스트 하도록 하겠습니다..

반응형
Comments