chrome外掛: yapi 介面TypeScript程式碼生成器

發表於2020-09-21

前言

2020-09-12 天氣晴,藍天白雲,微風,甚好。

前端Jser一枚,在公司的電腦前,瀏覽器開啟著yapi的介面文件,那密密麻麻的介面資料,要一個一個的去敲打成為TypeScript的interface或者type。 心煩。

雖然這樣的情況已經持續了大半年了,也沒什麼人去抱怨。 在程式中的any卻是出現了不少, 今any , 明天又any, 贏得了時間,輸了維護。 換個人來一看, what, 這個是what , 那個是what. 就算是過了一段時間,找來最初編寫程式碼的人來看,也是what what what。 這時候 TS 真香定律就出來了。

我是開始寫程式碼? 還是開始寫生成程式碼的程式碼?

扔個硬幣, 頭像朝上開始寫程式碼,反之,寫生成程式碼的程式碼。 第一次頭像朝上, 不行, 一次不算, 第二次, 還是頭像朝上,那5局3剩吧, 第三次,依舊是頭像朝上,呵呵,苦笑一下。 我就扔完五局,看看這上天幾個意思。 第四次,頭像朝向, 第五次頭像朝向。 呵呵噠。

好吧,既然這樣。 切,我命由我不由天,開始寫生成程式碼的程式碼。

於是才有了今天的這篇部落格。

分析

考慮到TypeScript程式碼生成這塊, 大體思路

  • 方案一: 現有開源的專案.
  • 方案二: yapi外掛
  • 方案三: 後端專案開放許可權, 直接copy。 因為後端也是使用node + TS 來編寫的,這不太可能。
  • 方案四: yapi上做手腳,讀取原始的介面後設資料,生成TS。
    • 直接運算元據庫
    • 操作介面
    • 頁面裡面扣

方案一
現在開源的專案能找到的是

  • yapi-to-typescript
    yapi-to-typescript非常強大和成熟, 推薦大家使用。
  • sm2tsservice
    這個也很強大,不過需要進行一些配置。

方案二
yapi-plugin-response-to-ts 看起來還不錯,19年5月更新的。

接下來說的是 方案四的一種

經過對 /project/[project id]/interface/api/[api id] 頁面的觀察。
發現頁面是在載入之後發出一個http請求,路徑為 /api/interface/get?id=[api id], 這裡的api id和頁面路徑上的那個api id是一個值。
介面返回的資料格式如下:

{
    "errcode": 0,
    "errmsg": "成功!",
    "data": {
        "query_path": {
            "path": "/account/login",
            "params": []
        },
        "req_body_is_json_schema": true,
        "res_body_is_json_schema": true,
        "title": "登入",
        "path": "/account/login",
        "req_params": [],
        "res_body_type": "json",
        "req_query": [],
        // 請求的頭資訊
        "req_headers": [
            {
                "required": "1",
                "name": "Content-Type",
                "value": "application/x-www-form-urlencoded"
            }
        ],
        // 請求的表單資訊
        "req_body_form": [
            {
                "required": "1",
                "name": "uid",
                "type": "text",
                "example": "1",
                "desc": "number"
            },
            {
                "required": "1",
                "name": "pwd",
                "type": "text",
                "example": "123456",
                "desc": "string"
            }
        ],
        "req_body_type": "form",
        // 返回的結果
        "res_body": "{\"$schema\":\"http://json-schema.org/draft-04/schema#\",\"type\":\"object\",\"properties\":{\"errCode\":{\"type\":\"number\",\"mock\":{\"mock\":\"0\"}},\"data\":{\"type\":\"object\",\"properties\":{\"token\":{\"type\":\"string\",\"mock\":{\"mock\":\"sdashdha\"},\"description\":\"請求header中設定Authorization為此值\"}},\"required\":[\"token\"]}},\"required\":[\"errCode\",\"data\"]}"
    }
}

比較有用的資訊是

  • req_query
  • req_headers
  • req_body_type 請求的資料型別 form|json等等
  • req_body_form req_body_type 為form時有資料,資料長度大於0
  • req_body_other req_body_type 為json時有資料
  • res_body_type 返回的資料型別 form | json
  • res_body res_body_type返回為json時有資料

我們專案上, req_body_type只用到了 form 或者 json。 res_body_type只使用了 json.

我們把res_body格式化一下

{
    "$schema": "http://json-schema.org/draft-04/schema#",
    "type": "object",
    "properties": {
        "errCode": {
            "type": "number",
            "mock": {
                "mock": "0"
            }
        },
        "data": {
            "type": "object",
            "properties": {
                "token": {
                    "type": "string",
                    "mock": {
                        "mock": "sdashdha"
                    },
                    "description": "請求header中設定Authorization為此值"
                }
            },
            "required": [
                "token"
            ]
        }
    },
    "required": [
        "errCode",
        "data"
    ]
}

上面大致對應如下的資料

{
    "errCode": 0,
    "data":{
        "token": ""
    }
}

到這裡可以開始寫生成程式碼的程式碼了。
這裡要處理兩個格式的資料,一種是form對應的陣列資料,一種是json對應的資料。

程式碼

先定義一下介面

export interface BodyTransFactory<T, V> {
    buildBodyItem(data: T): string;

    buildBody(name: string, des: string, data: V): string;
}

form資料格式

定義型別為form資料的格式

export interface FormItem {
    desc: string;
    example: string;
    name: string;
    required: string;
    type: string;
}

程式碼實現

import { BodyTransFactory, FormItem } from "../types";

const TYPE_MAP = {
    "text": "string"
}


export default class Factory implements BodyTransFactory<FormItem, FormItem[]> {
    buildBodyItem(data: FormItem) {
        return `
    /**
     * ${data.desc}
    */    
    ${data.name}${data.required == "0" ? "?" : ""}: ${TYPE_MAP[data.type] || data.type}; `
    }


    buildBody(name: string, des: string = "", data: FormItem[]) {
        return (`  
/**
 * ${des}
 */
export interface ${name}{
    ${data.map(d => this.buildBodyItem(d)).join("")}

}`)
    }
}

json資料格式

定義資料

interface Scheme {
    // string|number|object|array等
    type: string;  
    // type 為 object的時候,該屬性存在
    properties?: Record<string, Scheme>;  
    // type為object 或者array的時候,該屬性存在,標記哪些屬性是必須的
    required?: string[];
    // 描述
    description?: string;
    // 當type為array的時候,該屬性存在
    items?: Scheme;
}

這裡的注意一下,如果type為object的時候,properties屬性存在,如果type為array的時候, items存在。
這裡的required標記哪些屬性是必須的。

一看著資料格式,我們就會潛意思的想到一個詞,遞迴,沒錯,遞迴。

屬性每巢狀以及,就需要多四個(或者2個)空格,那先來一個輔助方法:

function createContentWithTab(content = "", level = 0) {
    return "    ".repeat(level) + content;
}

注意到了,這裡有個level,是的,標記遞迴的深度。 接下來看核心程式碼:
trans方法的level,result完全可以放到類屬性上,不放也有好處,完全獨立。
核心就是區分是不是葉子節點,不是就遞迴,還有控制level的值。

import { BodyTransFactory } from "../types";
import { isObject } from "../util";

interface Scheme {
    type: string;
    properties?: Record<string, Scheme>;
    required?: string[];
    description?: string;
    items?: Scheme;
}

function createContentWithTab(content = "", level = 0) {
    return "    ".repeat(level) + content;
}

export default class Factory implements BodyTransFactory<Scheme, Scheme> {

    private trans(data: Scheme, level = 0, result = [], name = null) {

        // 物件
        if (data.type === "object") {
            result.push(createContentWithTab(name ? `${name}: {` : "{", level));
            level++;
            const requiredArr = (data.required || []);

            for (let p in data.properties) {
                const v = data.properties[p];
                if (!isObject(v)) {
                    if (v.description) {
                        result.push(createContentWithTab(`/**`, level));
                        result.push(createContentWithTab(`/*${v.description}`, level));
                        result.push(createContentWithTab(` */`, level));
                    }
                    const required = requiredArr.includes(p);
                    result.push(createContentWithTab(`${p}${required ? "" : "?"}: ${v.type};`, level));
                } else {
                    this.trans(v, level, result, p);
                }
            }
            result.push(createContentWithTab("}", level - 1));
        } else if (data.type === "array") { // 陣列
            // required 還沒處理呢,哈哈
            // 陣列成員非物件
            if (data.items.type !== "object") {
                result.push(createContentWithTab(name ? `${name}: ${data.items.type}[]` : `${data.items.type}[]`, level));             
            } else { // 陣列成員是物件
                result.push(createContentWithTab(name ? `${name}: [{` : "[{", level));
                level++;
                for (let p in data.items.properties) {
                    const v = data.items.properties[p];
                    if (!isObject(v)) {
                        if (v.description) {
                            result.push(createContentWithTab(`/**`, level));
                            result.push(createContentWithTab(`/*${v.description}`, level));
                            result.push(createContentWithTab(`*/`, level));
                        }
                        result.push(createContentWithTab(`${p}: ${v.type};`, level));
                    } else {
                        this.trans(v, level, result, p);
                    }
                }
                result.push(createContentWithTab("}]", level - 1));
            }
        }
        return result;
    }


    buildBodyItem(data: Scheme) {
        return null;
    }

    buildBody(name: string, des: string, data: Scheme) {
        const header = [];
        header.push(createContentWithTab(`/**`, 0));
        header.push(createContentWithTab(`/*${des}`, 0));
        header.push(createContentWithTab(`*/`, 0));


        const resutlArr = this.trans(data, 0, []);

        // 修改第一行
        const fline = `export interface ${name} {`
        resutlArr[0] = fline;

        // 插入說明
        const result = [...header, ...resutlArr, '\r\n']

        return result.join("\r\n")
    }
}


兩個工廠有了,合成一下。 當然其實呼叫邏輯還可以再封裝一層。

import SchemeFactory from "./scheme";
import FormFactory from "./form";


export default {
    scheme: new SchemeFactory(),
    form: new FormFactory()
}

在上主邏輯


import factory from "./factory";
import { ResApiData } from "./types";
import * as util from "./util";
import ajax from "./ajax";

// 拼接請求地址
function getUrl() {
    const API_ID = location.href.split("/").pop();
    return "/api/interface/get?id=" + API_ID;
}

// 提取地址資訊
function getAPIInfo(url: string = "") {
    const urlArr = url.split("/");
    const len = urlArr.length;
    return {
        category: urlArr[len - 2],
        name: urlArr[len - 1]
    }
}

function onSuccess(res: ResApiData, cb) {
    if (res.errcode !== 0 || !res.data) {
        return alert("獲取介面基本資訊失敗");
    }
    trans(res, cb);
}

// 核心流程程式碼
function trans(res: ResApiData, cb: CallBack) {
    const apiInfo = getAPIInfo(res.data.path);

    let reqBodyTS: any;
    let resBodyTs: any;

    const reqBodyName = util.fUp(apiInfo.name);
    const reqBodyDes = res.data.title + "引數";

    // 更合適是通過 res.data.req_body_type
    if (res.data.req_body_other && typeof res.data.req_body_other === "string") {
        reqBodyTS = factory.scheme.buildBody(reqBodyName + "Param", reqBodyDes, JSON.parse(res.data.req_body_other));
    } else if (Array.isArray(res.data.req_body_form)) {
        reqBodyTS = factory.form.buildBody(reqBodyName+ "Param", reqBodyDes, res.data.req_body_form)
    }

    const resBodyName = util.fUp(apiInfo.name);
    const resBodyDes = res.data.title;
    // // 更合適是通過 res.data.res_body_type
    if (res.data.res_body_is_json_schema) {
        resBodyTs = factory.scheme.buildBody(resBodyName+ "Data", resBodyDes, JSON.parse(res.data.res_body));
    } else {
        cb("res_body暫只支援scheme格式");
    }
    cb(null, {
        reqBody: reqBodyTS,
        resBody: resBodyTs,
        path: res.data.path
    })

}

export type CallBack = (err: any, data?: {
    reqBody: string;
    resBody: string;
    path: string;
}) => void;

export default function startTrans(cb: CallBack) {
    const url = getUrl();
    console.log("api url", url);
    ajax({
        url,
        success: res => {
            onSuccess(res, cb)
        },
        error: () => {
            cb("請求發生錯誤")
        }
    })
}

到這裡,其實主要的處理流程都已經完畢了。 當然還存在不少的遺漏和問題。
比如

  • form格式處理的時候TYPE_MAP資料還不完善
  • 請求資料格式只處理了form和json兩種型別
  • 返回資料資料格式只處理了json型別
  • 資料格式覆蓋問題

這畢竟只是花了半天時候弄出來的半成品,目前測試了不少介面,夠用。

到這裡,其實程式碼只能複製到瀏覽器窗體裡面去執行,體驗當然是太差了。

所以,我們再進一步,封裝到chrome外掛裡面。

chrome外掛

chrome外掛有好幾部分,我們這個只用content_script就應該能滿足需求了。

核心指令碼部分

  • 檢查域名和地址
  • 動態注入html元素
  • 註冊事件監聽

就這麼簡單。


import * as $ from 'jquery';
import startTrans from "./apits/index";
import { copyText, downloadFile } from "./apits/util";

; (function init() {

    if (document.location.host !== "") {
        return;
    }


    const $body = $(document.body);

    $body.append(`
        <div style="position:fixed; right:0; top:0;z-index: 99999;background: burlywood;">
            <input type="button" value="複製到剪貼簿" id='btnCopy' />
            <input type="button" value="匯出檔案" id='btnDownload' />
        </div>  
    `);


    $("#btnCopy").click(function () {
        startTrans(function (err, data) {
            if (err) {
                return alert(err);
            }
            const fullContent = [data.reqBody, "\r\n", data.resBody].join("\r\n");

            copyText(fullContent, ()=>{
                alert("複製成功");
            })

        });
    })

    $("#btnDownload").click(function () {
        startTrans(function (err, data) {
            if (err) {
                return alert(err);
            }
            const fullContent = [data.reqBody, "\r\n", data.resBody].join("\r\n");
            const name = data.path.split("/").pop();

            downloadFile(fullContent, `${name}.ts`)
        });
    })

})();

如上可以看到有兩種操作,一是複製到剪貼簿,而是下載。

複製剪貼版,內容貼到textarea元素,選中,執行document.execCommand('copy');

export function copyText(text, callback) {
    var tag = document.createElement('textarea');
    tag.setAttribute('id', 'cp_input_');
    tag.value = text;
    document.getElementsByTagName('body')[0].appendChild(tag);
    (document.getElementById('cp_input_') as HTMLInputElement).select();
    document.execCommand('copy');
    document.getElementById('cp_input_').remove();
    if (callback) { callback(text) }
}

下載的話, blob生成url, a標籤download屬性, 模擬點選。

export function downloadFile(content: string, saveName:string) {
    const blob = new Blob([content]);
    const url = URL.createObjectURL(blob);
    var aLink = document.createElement('a');
    aLink.href = url;
    aLink.download = saveName || ''; 
    var event;
    if (window.MouseEvent) event = new MouseEvent('click');
    else {
        event = document.createEvent('MouseEvents');
        event.initMouseEvent('click', true, false, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
    }
    aLink.dispatchEvent(event);
}

後續

  • 只是生成了資料結構,其實後面還可以生成請求介面部分的程式碼,一鍵生成之後,直接呼叫介面就好,專心寫你的業務。
  • 只能一個一個介面的生成,能否直接生成一個project下的全部介面程式碼。 這都可以基於此來做
  • 其他:等你來做

原始碼

這裡採用了TypeScript chrome外掛腳手架chrome-extension-typescript-starter

原始碼 chrome-ex-api-ts-generator

安裝chrome外掛

  • 下載上面的專案
  • npm install
  • 修改 src/content_scriptyour host 為具體的值,或者刪除
  if (document.location.host !== "your host") {
        return;
 }
  • npm run build
  • chrome瀏覽器開啟 chrome://extensions/
  • 專案dist檔案拖拽到 chrome://extensions/ tab頁面

相關文章