浏览代码

feat[service]: init

chenlei 2 年之前
父节点
当前提交
5c20adfcdf

+ 2 - 0
package.json

@@ -21,6 +21,8 @@
   "devDependencies": {
     "@changesets/cli": "^2.26.2",
     "@types/jest": "^29.5.3",
+    "isomorphic-fetch": "^3.0.0",
+    "reflect-metadata": "^0.1.13",
     "tslib": "^2.6.1",
     "typescript": "^5.1.6"
   }

+ 6 - 0
packages/service/babel.config.js

@@ -0,0 +1,6 @@
+module.exports = {
+  presets: [
+    ["@babel/preset-env", { targets: { node: "current" } }],
+    "@babel/preset-typescript",
+  ],
+};

+ 192 - 0
packages/service/jest.config.js

@@ -0,0 +1,192 @@
+/*
+ * For a detailed explanation regarding each configuration property, visit:
+ * https://jestjs.io/docs/configuration
+ */
+
+module.exports = {
+  // All imported modules in your tests should be mocked automatically
+  // automock: false,
+
+  // Stop running tests after `n` failures
+  // bail: 0,
+
+  // The directory where Jest should store its cached dependency information
+  // cacheDirectory: "/tmp/jest_rs",
+
+  // Automatically clear mock calls and instances between every test
+  // clearMocks: false,
+
+  // Indicates whether the coverage information should be collected while executing the test
+  // collectCoverage: false,
+
+  // An array of glob patterns indicating a set of files for which coverage information should be collected
+  // collectCoverageFrom: undefined,
+
+  // The directory where Jest should output its coverage files
+  // coverageDirectory: undefined,
+
+  // An array of regexp pattern strings used to skip coverage collection
+  // coveragePathIgnorePatterns: [
+  //   "/node_modules/"
+  // ],
+
+  // Indicates which provider should be used to instrument code for coverage
+  // coverageProvider: "babel",
+
+  // A list of reporter names that Jest uses when writing coverage reports
+  // coverageReporters: [
+  //   "json",
+  //   "text",
+  //   "lcov",
+  //   "clover"
+  // ],
+
+  // An object that configures minimum threshold enforcement for coverage results
+  // coverageThreshold: undefined,
+
+  // A path to a custom dependency extractor
+  // dependencyExtractor: undefined,
+
+  // Make calling deprecated APIs throw helpful error messages
+  // errorOnDeprecated: false,
+
+  // Force coverage collection from ignored files using an array of glob patterns
+  // forceCoverageMatch: [],
+
+  // A path to a module which exports an async function that is triggered once before all test suites
+  // globalSetup: undefined,
+
+  // A path to a module which exports an async function that is triggered once after all test suites
+  // globalTeardown: undefined,
+
+  // A set of global variables that need to be available in all test environments
+  // globals: {},
+
+  // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
+  // maxWorkers: "50%",
+
+  // An array of directory names to be searched recursively up from the requiring module's location
+  // moduleDirectories: [
+  //   "node_modules"
+  // ],
+
+  // An array of file extensions your modules use
+  // moduleFileExtensions: [
+  //   "js",
+  //   "jsx",
+  //   "ts",
+  //   "tsx",
+  //   "json",
+  //   "node"
+  // ],
+
+  // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
+  moduleNameMapper: {},
+
+  // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
+  // modulePathIgnorePatterns: [],
+
+  // Activates notifications for test results
+  // notify: false,
+
+  // An enum that specifies notification mode. Requires { notify: true }
+  // notifyMode: "failure-change",
+
+  // A preset that is used as a base for Jest's configuration
+  // preset: undefined,
+
+  // Run tests from one or more projects
+  // projects: undefined,
+
+  // Use this configuration option to add custom reporters to Jest
+  // reporters: undefined,
+
+  // Automatically reset mock state between every test
+  // resetMocks: false,
+
+  // Reset the module registry before running each individual test
+  // resetModules: false,
+
+  // A path to a custom resolver
+  // resolver: undefined,
+
+  // Automatically restore mock state between every test
+  // restoreMocks: false,
+
+  // The root directory that Jest should scan for tests and modules within
+  // rootDir: undefined,
+
+  // A list of paths to directories that Jest should use to search for files in
+  // roots: [
+  //   "<rootDir>"
+  // ],
+
+  // Allows you to use a custom runner instead of Jest's default test runner
+  // runner: "jest-runner",
+
+  // The paths to modules that run some code to configure or set up the testing environment before each test
+  setupFiles: ["../../scripts/jest-env-setup.js"],
+
+  // A list of paths to modules that run some code to configure or set up the testing framework before each test
+  // setupFilesAfterEnv: [],
+
+  // The number of seconds after which a test is considered as slow and reported as such in the results.
+  // slowTestThreshold: 5,
+
+  // A list of paths to snapshot serializer modules Jest should use for snapshot testing
+  // snapshotSerializers: [],
+
+  // The test environment that will be used for testing
+  // testEnvironment: "jest-environment-node",
+  testEnvironment: "jsdom",
+
+  // Options that will be passed to the testEnvironment
+  // testEnvironmentOptions: {},
+
+  // Adds a location field to test results
+  // testLocationInResults: false,
+
+  // The glob patterns Jest uses to detect test files
+  // testMatch: [
+  //   "**/__tests__/**/*.[jt]s?(x)",
+  //   "**/?(*.)+(spec|test).[tj]s?(x)"
+  // ],
+
+  // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
+  // testPathIgnorePatterns: [
+  //   "/node_modules/"
+  // ],
+
+  // The regexp pattern or array of patterns that Jest uses to detect test files
+  // testRegex: [],
+
+  // This option allows the use of a custom results processor
+  // testResultsProcessor: undefined,
+
+  // This option allows use of a custom test runner
+  // testRunner: "jest-circus/runner",
+
+  // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
+  // testURL: "http://localhost",
+
+  // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
+  // timers: "real",
+
+  // A map from regular expressions to paths to transformers
+  // transform: undefined,
+
+  // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
+  transformIgnorePatterns: ["node_modules\\/(?!\\.pnpm|@dage)"],
+
+  // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
+  // unmockedModulePathPatterns: undefined,
+
+  // Indicates whether each individual test should be reported during the run
+  // verbose: undefined,
+
+  // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
+  // watchPathIgnorePatterns: [],
+
+  // Whether to use watchman for file crawling
+  // watchman: true,
+};

+ 29 - 0
packages/service/package.json

@@ -0,0 +1,29 @@
+{
+  "name": "@dage/service",
+  "version": "1.0.0",
+  "description": "接口请求工具",
+  "module": "dist/index.js",
+  "main": "dist/index.js",
+  "typings": "dist/index.d.ts",
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "start": "tsc -b tsconfig.build.json  --watch",
+    "prebuild": "rimraf dist",
+    "build": "tsc -b tsconfig.build.json",
+    "test": "jest"
+  },
+  "keywords": [],
+  "author": "",
+  "license": "MIT",
+  "devDependencies": {
+    "@babel/core": "^7.22.10",
+    "@babel/preset-env": "^7.22.10",
+    "@babel/preset-typescript": "^7.22.5",
+    "jest": "^29.6.3"
+  },
+  "dependencies": {
+    "@dage/utils": "workspace:^"
+  }
+}

+ 25 - 0
packages/service/src/Header.ts

@@ -0,0 +1,25 @@
+function transformKey(key: string | symbol): string | symbol {
+  return typeof key === "symbol" ? key : key.toLowerCase();
+}
+
+/**
+ * HTTP 报头比较特殊,需要兼容不同的大小写的 key
+ */
+export function createHeader(): Record<string, string> {
+  const headers = Object.create(null);
+
+  return new Proxy(headers, {
+    set(target, key, value) {
+      return Reflect.set(target, transformKey(key), value);
+    },
+    get(target, key) {
+      return Reflect.get(target, transformKey(key));
+    },
+    has(target, key) {
+      return Reflect.has(target, transformKey(key));
+    },
+    deleteProperty(target, key) {
+      return Reflect.deleteProperty(target, transformKey(key));
+    },
+  });
+}

+ 21 - 0
packages/service/src/compose.ts

@@ -0,0 +1,21 @@
+import { Interceptor } from "./types";
+
+/**
+ * 复合拦截器
+ * @example compose((request, next) => {}, (request, next) => {})
+ */
+export function compose(...interceptors: Interceptor[]): Interceptor {
+  return (request, next) => {
+    const combined = (index: number): ReturnType<Interceptor> => {
+      if (index === interceptors.length) {
+        // 结尾
+        return next();
+      } else {
+        const interceptor = interceptors[index];
+        return interceptor(request, () => combined(index + 1));
+      }
+    };
+
+    return combined(0);
+  };
+}

+ 3 - 0
packages/service/src/index.ts

@@ -0,0 +1,3 @@
+export * from "./request";
+export * from "./types";
+export * from "./compose";

+ 34 - 0
packages/service/src/replace.ts

@@ -0,0 +1,34 @@
+import { get } from "lodash";
+
+/**
+ * 必须是合法的标识符
+ */
+export const PLACEHOLDER_REGEXP = /\{\s*(['"a-zA-Z0-9_$.[\]]+)\s*\}/gm;
+
+export function variableReplace(
+  variables: Record<string, any>,
+  local: Record<string, any>
+) {
+  Object.keys(variables).forEach((key) => {
+    const value = variables[key];
+    if (typeof value === "string") {
+      const replaced = stringReplace(value, local);
+      if (replaced !== value) {
+        variables[key] = replaced;
+      }
+    }
+  });
+
+  return variables;
+}
+
+export function stringReplace(source: string, local: Record<string, any>) {
+  // 快捷判断
+  if (!source.includes("{")) {
+    return source;
+  }
+
+  return source.replace(PLACEHOLDER_REGEXP, (m, p) => {
+    return get(local, p);
+  });
+}

+ 329 - 0
packages/service/src/request.test.ts

@@ -0,0 +1,329 @@
+import { initial, isInitialized, request, requestPagination } from "./request";
+
+const fetchSuccess = jest.fn(() =>
+  Promise.resolve({
+    ok: true,
+    json: () => Promise.resolve({ data: "test", success: true }),
+    headers: new Map(),
+  })
+);
+
+// 原样返回
+const fetchEcho = jest.fn((url: string, options: RequestInit) => {
+  return Promise.resolve({
+    ok: true,
+    json: () =>
+      Promise.resolve({
+        data: JSON.parse(options.body as string),
+        success: true,
+      }),
+    headers: new Map(),
+  });
+});
+
+afterEach(() => {
+  fetchSuccess.mockClear();
+  fetchEcho.mockClear();
+});
+
+test("initial", async () => {
+  expect(request("test")).rejects.toThrow("请先调用 initial 初始化");
+
+  initial({
+    baseURL: "https://example.com",
+    fetch: (() => {}) as any,
+  });
+
+  expect(isInitialized).toBeTruthy();
+});
+
+test("request", async () => {
+  const fetch = fetchSuccess;
+
+  initial({
+    baseURL: "https://example.com",
+    fetch: fetch as any,
+  });
+
+  expect(
+    await request(
+      "/test",
+      { foo: "bar" },
+      {
+        method: "PUT",
+        searchParams: { one: "two" },
+        headers: {
+          "X-POWER-BY": "test",
+        },
+      }
+    )
+  ).toEqual("test");
+
+  expect(fetch).toBeCalledWith("https://example.com/test?one=two", {
+    body: '{"foo":"bar"}',
+    headers: { "content-type": "application/json", "x-power-by": "test" },
+    method: "PUT",
+    mode: "cors",
+  });
+});
+
+test("name 参数变量替换", async () => {
+  const fetch = fetchSuccess;
+  initial({
+    baseURL: "https://example.com",
+    fetch: fetch as any,
+  });
+
+  request("/test/{id}/{foo.bar}", { id: "123", foo: { bar: "baz" } });
+
+  expect(fetch).toBeCalledWith("https://example.com/test/123/baz", {
+    body: '{"id":"123","foo":{"bar":"baz"}}',
+    headers: { "content-type": "application/json" },
+    method: "POST",
+    mode: "cors",
+  });
+});
+
+test("支持识别 application/x-www-form-urlencoded", async () => {
+  const fetch = fetchSuccess;
+  initial({
+    baseURL: "https://example.com",
+    fetch: fetch as any,
+  });
+
+  expect(
+    await request(
+      "/test",
+      { foo: "bar", baz: 1 },
+      {
+        method: "POST",
+        headers: { "Content-Type": "application/x-www-form-urlencoded" },
+      }
+    )
+  ).toEqual("test");
+
+  expect(fetch).toBeCalledWith("https://example.com/test", {
+    body: "baz=1&foo=bar",
+    headers: { "content-type": "application/x-www-form-urlencoded" },
+    method: "POST",
+    mode: "cors",
+  });
+});
+
+test("拦截器", async () => {
+  const fetch = fetchSuccess;
+
+  initial({
+    baseURL: "https://example.com",
+    fetch: fetch as any,
+    interceptor: async (req, next) => {
+      req.headers["X-POWER-BY"] = "test";
+      req.searchParams.one = "two";
+      req.method = "GET";
+      req.body.foo = "foo";
+
+      const response = await next();
+      response.data = "boom";
+
+      return response;
+    },
+  });
+
+  expect(await request("/test", { bar: "bar" })).toEqual("boom");
+  expect(fetch).toBeCalledWith(
+    "https://example.com/test?bar=bar&foo=foo&one=two",
+    {
+      body: undefined,
+      headers: { "content-type": "application/json", "x-power-by": "test" },
+      method: "GET",
+      mode: "cors",
+    }
+  );
+});
+
+test("拦截器元数据", async () => {
+  const fetch = fetchSuccess;
+  const interceptor = jest.fn((req, next) => {
+    return next();
+  });
+
+  initial({
+    baseURL: "https://example.com",
+    fetch: fetch as any,
+    interceptor,
+  });
+
+  // @ts-expect-error
+  request("/test", {}, { meta: { foo: "bar" } });
+  expect(interceptor.mock.calls[0][0]).toEqual({
+    body: {},
+    headers: { "content-type": "application/json" },
+    // 拦截到元数据
+    meta: { foo: "bar" },
+    method: "POST",
+    name: "/test",
+    searchParams: {},
+  });
+
+  expect(fetch).toBeCalledWith("https://example.com/test", {
+    body: "{}",
+    headers: { "content-type": "application/json" },
+    method: "POST",
+    mode: "cors",
+  });
+});
+
+test("拦截重试, 用于重新登录等复杂场景", async () => {
+  const fetch = fetchEcho;
+  initial({
+    baseURL: "https://example.com",
+    fetch: fetch as any,
+    interceptor: async (req, next) => {
+      req.body.time = 1;
+      await next();
+      // 二次重试请求
+      req.body.time = 2;
+      return await next();
+    },
+  });
+  const result = await request("/test");
+  // 返回最后一次 next 的请求
+  expect(result).toEqual({ time: 2 });
+  // fetch 被调用两次
+  expect(fetch).toBeCalledWith("https://example.com/test", {
+    body: '{"time":1}',
+    headers: { "content-type": "application/json" },
+    method: "POST",
+    mode: "cors",
+  });
+  expect(fetch).toBeCalledWith("https://example.com/test", {
+    body: '{"time":2}',
+    headers: { "content-type": "application/json" },
+    method: "POST",
+    mode: "cors",
+  });
+});
+
+test("拦截重试,调用 next 多次应该抛出异常", async () => {
+  const fetch = fetchSuccess;
+  initial({
+    baseURL: "https://example.com",
+    fetch: fetch as any,
+    interceptor: async (req, next) => {
+      while (true) {
+        await next();
+      }
+    },
+  });
+
+  await expect(request("/test")).rejects.toThrow(
+    "拦截器调用 next 次数过多, 请检查代码,可能存在无限循环"
+  );
+  expect(fetch).toBeCalledTimes(3);
+});
+
+test("响应内容规范化 - error", async () => {
+  const fetch = jest.fn(() =>
+    Promise.resolve({
+      ok: true,
+      json: () =>
+        Promise.resolve({
+          data: "test",
+          success: false,
+          errorCode: 400,
+          errorMessage: "mock error",
+        }),
+      headers: new Map(),
+    })
+  );
+
+  initial({
+    baseURL: "https://example.com",
+    fetch: fetch as any,
+  });
+
+  try {
+    await request("/test");
+    // never
+    expect(true).toBe(false);
+  } catch (err) {
+    expect(err).toMatchObject({ code: 400, message: "mock error" });
+  }
+});
+
+test("响应内容规范化 - success", async () => {
+  const fetch = jest.fn(() =>
+    Promise.resolve({
+      ok: true,
+      json: () =>
+        Promise.resolve({
+          data: "test",
+          success: true,
+          code: 400,
+          msg: "mock error",
+          other: "foo",
+        }),
+      headers: new Map(),
+    })
+  );
+
+  initial({
+    baseURL: "https://example.com",
+    fetch: fetch as any,
+  });
+
+  expect(await requestPagination("/test")).toEqual({
+    __raw__: {
+      data: {
+        code: 400,
+        data: "test",
+        msg: "mock error",
+        other: "foo",
+        success: true,
+      },
+      header: {},
+      statusCode: undefined,
+    },
+    data: "test",
+    code: 400,
+    msg: "mock error",
+    other: "foo",
+    success: true,
+  });
+});
+
+test("变量替换", async () => {
+  const fetch = fetchSuccess;
+
+  initial({
+    baseURL: "https://example.com",
+    fetch: fetch as any,
+    globalVariables: { appId: "mock" },
+  });
+
+  expect(
+    await request(
+      "/test",
+      { foo: "bar", myId: "{appId}" },
+      {
+        method: "PUT",
+        searchParams: { one: "two", myId: "{appId}" },
+        headers: {
+          "X-POWER-BY": "test",
+          "MY-ID": "{appId}",
+        },
+      }
+    )
+  ).toEqual("test");
+
+  expect(fetch).toBeCalledWith("https://example.com/test?myId=mock&one=two", {
+    body: '{"foo":"bar","myId":"mock"}',
+    headers: {
+      "content-type": "application/json",
+      "x-power-by": "test",
+      "my-id": "mock",
+    },
+    method: "PUT",
+    mode: "cors",
+  });
+});

+ 310 - 0
packages/service/src/request.ts

@@ -0,0 +1,310 @@
+import {
+  NoopObject,
+  addTrailingSlash,
+  queryString,
+  removeHeadingSlash,
+} from "@dage/utils";
+import { createHeader } from "./Header";
+import { stringReplace, variableReplace } from "./replace";
+import {
+  DageRequest,
+  DageRequestConfig,
+  DageResponse,
+  Interceptor,
+  PaginationParams,
+  PaginationResponse,
+} from "./types";
+import {
+  detectContentType,
+  headersToObject,
+  methodsHasBody,
+  serializeBody,
+  serializeResponseBody,
+} from "./utils";
+
+export interface ServiceAdapter {
+  baseURL: string;
+  fetch: typeof fetch;
+  /**
+   * 拦截器
+   */
+  interceptor?: Interceptor;
+  /**
+   * 全局变量
+   */
+  globalVariables?: Record<string, any>;
+}
+
+const DEFAULT_INTERCEPTOR: Interceptor = (_, next) => {
+  return next();
+};
+
+export function createService() {
+  let adapter: ServiceAdapter;
+  let initialized = false;
+
+  /**
+   * 是否初始化完成
+   * @returns
+   */
+  function isInitialized() {
+    return initialized;
+  }
+
+  /**
+   * 获取 Base URL
+   */
+  function getBaseURL() {
+    if (!initialized) {
+      throw new Error("未初始化");
+    }
+
+    return adapter.baseURL;
+  }
+
+  /**
+   * 解析响应
+   */
+  function parseResponse(
+    response: DageResponse | PaginationResponse
+  ): DageResponse | PaginationResponse {
+    if (response == null) {
+      throw {
+        message: "未能识别响应",
+        code: -1,
+      };
+    }
+
+    return response;
+  }
+
+  /**
+   * 初始化
+   * @param options
+   */
+  function initial(options: ServiceAdapter) {
+    if (initialized && process.env.NODE_ENV === "development") {
+      console.error("[@dage/service] 已经初始化过了, 不需要重复初始化");
+    }
+
+    adapter = options;
+
+    adapter.baseURL = addTrailingSlash(options.baseURL);
+
+    initialized = true;
+  }
+
+  async function normalizeRequest<R = any, P extends {} = any>(
+    url: string,
+    body?: P,
+    config: DageRequestConfig = NoopObject
+  ): Promise<PaginationResponse<R> | DageResponse<R>> {
+    if (!initialized) {
+      throw new Error("请先调用 initial 初始化");
+    }
+
+    const method = config.method ?? "POST";
+    const searchParams: Record<string, string> = {};
+    const reqBody = body ?? NoopObject;
+
+    // 路由参数替换
+    url = stringReplace(url, reqBody);
+
+    // 查询字符串处理
+    const appendSearchParams = (
+      obj: Record<string, string>,
+      params = searchParams
+    ) => {
+      Object.assign(params, obj);
+    };
+
+    if (config.searchParams) {
+      appendSearchParams(config.searchParams);
+    }
+
+    // 报头处理
+    const headers: Record<string, string> = createHeader();
+
+    if (config.headers) {
+      Object.keys(config.headers).forEach((key) => {
+        headers[key] = config.headers![key];
+      });
+    }
+
+    if (methodsHasBody(method) && !headers["Content-Type"]) {
+      const contentType = detectContentType(reqBody);
+      if (contentType) {
+        headers["Content-type"] = contentType;
+      }
+    }
+
+    const requestPayload: DageRequest = {
+      name: url,
+      method,
+      body: reqBody,
+      searchParams,
+      headers,
+      meta: config.meta ?? NoopObject,
+    };
+
+    // 变量替换
+    if (adapter.globalVariables) {
+      variableReplace(requestPayload.body, adapter.globalVariables);
+      variableReplace(requestPayload.searchParams, adapter.globalVariables);
+      variableReplace(requestPayload.headers, adapter.globalVariables);
+    }
+
+    const interceptor = adapter.interceptor ?? DEFAULT_INTERCEPTOR;
+    // next 方法被拦截器调用的次数,如果调用次数过多,说明程序存在bug,不排除有无限循环的可能
+    let nextCallTime = 0;
+
+    const response = await interceptor(requestPayload, async () => {
+      nextCallTime++;
+
+      // 最多 3 次
+      if (nextCallTime > 3) {
+        throw new Error(
+          "拦截器调用 next 次数过多, 请检查代码,可能存在无限循环"
+        );
+      }
+
+      if (requestPayload.body && requestPayload.method === "GET") {
+        appendSearchParams(requestPayload.body, requestPayload.searchParams);
+      }
+
+      // 构建 href
+      let href = url.startsWith("http")
+        ? url
+        : adapter.baseURL + removeHeadingSlash(url);
+
+      if (Object.keys(requestPayload.searchParams).length) {
+        const temp = queryString.parseUrl(href);
+        href = queryString.stringifyUrl({
+          url: temp.url,
+          query: { ...temp.query, ...requestPayload.searchParams },
+          fragmentIdentifier: temp.fragmentIdentifier,
+        });
+      }
+
+      /**
+       * 主体处理
+       */
+      const finalBody =
+        requestPayload.body && methodsHasBody(requestPayload.method)
+          ? serializeBody(requestPayload.headers, requestPayload.body)
+          : undefined;
+
+      const res = await adapter.fetch(href, {
+        method: requestPayload.method,
+        headers: requestPayload.headers,
+        body: finalBody,
+        mode: "cors",
+      });
+
+      if (!res.ok) {
+        console.error(res);
+        throw new Error(`[@dage/service]请求 ${url} 失败: ${res.status}`);
+      }
+
+      const resData = await serializeResponseBody(res, config);
+
+      // @ts-expect-error
+      const clone: DageResponse = {
+        ...resData,
+        // 原始请求信息
+        __raw__: {
+          statusCode: res.status,
+          data: resData,
+          header: headersToObject(res.headers),
+        },
+      };
+
+      return clone as DageResponse | PaginationResponse;
+    });
+
+    // 解析协议
+    return parseResponse(response);
+  }
+
+  /**
+   * 请求方法,默认使用 POST 方法
+   */
+  async function request<R = any, P extends {} = any>(
+    url: string,
+    body?: P,
+    config?: DageRequestConfig
+  ): Promise<R> {
+    return (await normalizeRequest(url, body, config)).data;
+  }
+
+  const requestByPost = request;
+
+  async function requestByGet<R = any, P extends {} = any>(
+    url: string,
+    body?: P,
+    config?: DageRequestConfig
+  ): Promise<R> {
+    return (await normalizeRequest(url, body, { method: "GET", ...config }))
+      .data;
+  }
+
+  /**
+   * 列表接口请求, 默认为 POST 请求
+   */
+  async function requestPagination<R = any, P extends PaginationParams = any>(
+    name: string,
+    body?: P,
+    config?: DageRequestConfig
+  ): Promise<PaginationResponse<R>> {
+    return (await normalizeRequest(name, body, {
+      method: "POST",
+      ...config,
+    })) as PaginationResponse<R>;
+  }
+
+  return {
+    getBaseURL,
+    parseResponse,
+    initial,
+    isInitialized,
+    request,
+    requestByPost,
+    requestByGet,
+    requestPagination,
+  };
+}
+
+const DEFAULT_SERVICE = createService();
+
+/**
+ * 获取 Base URL
+ * @returns
+ */
+export const getBaseURL = DEFAULT_SERVICE.getBaseURL;
+
+/**
+ * 解析响应
+ * @param response
+ * @returns
+ */
+export const parseResponse = DEFAULT_SERVICE.parseResponse;
+
+/**
+ * 是否初始化完成
+ * @returns
+ */
+export const isInitialized = DEFAULT_SERVICE.isInitialized;
+
+/**
+ * 初始化
+ * @param options
+ */
+export const initial = DEFAULT_SERVICE.initial;
+
+export const request = DEFAULT_SERVICE.request;
+
+export const requestByPost = DEFAULT_SERVICE.requestByPost;
+
+export const requestByGet = DEFAULT_SERVICE.requestByGet;
+
+export const requestPagination = DEFAULT_SERVICE.requestPagination;

+ 87 - 0
packages/service/src/types.ts

@@ -0,0 +1,87 @@
+declare global {
+  /**
+   * 接口请求元数据,由应用自行定义,可以在 拦截器中获取
+   */
+  interface DageRequestMeta {
+    /**
+     * 响应正文格式
+     * 默认为 `json`
+     */
+    responseType?: "text" | "arrayBuffer" | "blob" | "json";
+  }
+}
+
+/**
+ * 规范化
+ */
+export interface CommonResponse<T = any> {
+  data: T;
+  code: number;
+  errorCode?: number;
+  errorMessage?: string;
+
+  /**
+   * 原始响应
+   */
+  __raw__: {
+    data: any;
+    statusCode: number;
+    header: Record<string, string>;
+  };
+}
+
+/**
+ * 响应对象
+ */
+export interface DageResponse<T = any> extends CommonResponse<T> {}
+
+export interface DageRequest extends Required<DageRequestConfig> {
+  name: string;
+  body: Record<string, any>;
+}
+
+export type DageRequestMethod =
+  | "GET"
+  | "POST"
+  | "DELETE"
+  | "PUT"
+  | "PATCH"
+  | "HEAD";
+
+export interface DageRequestConfig {
+  /**
+   * 默认为 POST
+   */
+  method?: DageRequestMethod;
+  /**
+   * 查询字符串
+   */
+  searchParams?: Record<string, string>;
+  /**
+   * 报头
+   */
+  headers?: Record<string, string>;
+  /**
+   * 自定义元数据, 可以用于拦截
+   */
+  meta?: DageRequestMeta;
+}
+
+/**
+ * 拦截器,可以修改 request 对象,调用 next 发起请求
+ */
+export type Interceptor = (
+  request: DageRequest,
+  next: () => Promise<DageResponse | PaginationResponse>
+) => Promise<DageResponse | PaginationResponse>;
+
+export interface PaginationResponse<T = any> extends CommonResponse<T[]> {
+  total: number;
+  size: number;
+  pages: number;
+}
+
+export interface PaginationParams {
+  pageSize: number;
+  pageNum: number;
+}

+ 114 - 0
packages/service/src/utils.ts

@@ -0,0 +1,114 @@
+import { queryString } from "@dage/utils";
+import { DageRequestConfig, DageRequestMethod } from "./types";
+
+/**
+ * 转换 Headers 为 对象形式
+ * @param headers
+ */
+export function headersToObject(headers: Headers): Record<string, string> {
+  return Array.from(headers.entries()).reduce<Record<string, string>>(
+    (prev, cur) => {
+      const [key, value] = cur as [string, string];
+      prev[key] = value;
+      return prev;
+    },
+    {}
+  );
+}
+
+/**
+ * 能携带参数的请求方法
+ * @param method
+ */
+export function methodsHasBody(method: DageRequestMethod) {
+  return method !== "GET" && method !== "HEAD";
+}
+
+/**
+ * 序列化载荷
+ * @param headers
+ * @param body
+ */
+export function serializeBody(headers: Record<string, string>, body: any) {
+  if (body == null) {
+    return body;
+  }
+
+  if (isArrayBuffer(body) || isBlob(body) || isFormData(body)) {
+    return body;
+  }
+
+  const contentType = headers["content-type"];
+
+  if (contentType === "application/x-www-form-urlencoded") {
+    // 序列化为查询字符串
+    return queryString.stringify(body);
+  }
+
+  return JSON.stringify(body);
+}
+
+export function isFormData(value: any): boolean {
+  return typeof FormData !== "undefined" && value instanceof FormData;
+}
+
+export function isBlob(value: any): boolean {
+  return typeof Blob !== "undefined" && value instanceof Blob;
+}
+
+export function isArrayBuffer(value: any): boolean {
+  return typeof ArrayBuffer !== "undefined" && value instanceof ArrayBuffer;
+}
+
+/**
+ * 根据body生成对应的contentType
+ * @param body http body
+ */
+export function detectContentType(body: any): string | null {
+  if (body == null) {
+    return null;
+  }
+  if (isFormData(body) || isArrayBuffer(body)) {
+    return null;
+  }
+  if (isBlob(body)) {
+    return body.type || null;
+  }
+
+  if (
+    typeof body === "object" ||
+    typeof body === "number" ||
+    Array.isArray(body)
+  ) {
+    return "application/json;charset=UTF-8";
+  }
+
+  return "application/x-www-form-urlencoded;charset=UTF-8";
+}
+
+export function getResponseType(
+  config: DageRequestConfig
+): DageRequestMeta["responseType"] {
+  const meta = config.meta;
+  return meta?.responseType || "json";
+}
+
+export function serializeResponseBody(
+  res: globalThis.Response,
+  config: DageRequestConfig
+): Promise<Record<string, any> & { data: any }> {
+  const responseType = getResponseType(config);
+
+  switch (responseType) {
+    case "arrayBuffer":
+      return res.arrayBuffer().then((data) => ({ data }));
+    case "blob":
+      return res.blob().then((data) => ({ data }));
+    case "json":
+      return res.json();
+    case "text":
+      return res.text().then((data) => ({ data }));
+    default:
+      return Promise.reject(new Error("传入的meta.responseType 不符合规范"));
+  }
+}

+ 7 - 0
packages/service/tsconfig.build.json

@@ -0,0 +1,7 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "noEmit": false
+  },
+  "exclude": ["dist", "node_modules", "*.d.ts", "**/*.test.*"]
+}

+ 73 - 0
packages/service/tsconfig.json

@@ -0,0 +1,73 @@
+{
+  "compilerOptions": {
+    /* Visit https://aka.ms/tsconfig.json to read more about this file */
+
+    /* Basic Options */
+    // "incremental": true,                         /* Enable incremental compilation */
+    "target": "ES2015" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */,
+    "module": "ESNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
+    // "lib": [],                                   /* Specify library files to be included in the compilation. */
+    // "allowJs": true,                             /* Allow javascript files to be compiled. */
+    // "checkJs": true,                             /* Report errors in .js files. */
+    "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */,
+    "declaration": true /* Generates corresponding '.d.ts' file. */,
+    "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */,
+    "sourceMap": false /* Generates corresponding '.map' file. */,
+    // "outFile": "./",                             /* Concatenate and emit output to single file. */
+    "outDir": "./dist" /* Redirect output structure to the directory. */,
+    "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
+    // "composite": true,                           /* Enable project compilation */
+    // "tsBuildInfoFile": "./",                     /* Specify file to store incremental compilation information */
+    // "removeComments": true,                      /* Do not emit comments to output. */
+    "noEmit": true /* Do not emit outputs. */,
+    "importHelpers": true /* Import emit helpers from 'tslib'. */,
+    // "downlevelIteration": true,                  /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
+    // "isolatedModules": true,                     /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
+
+    /* Strict Type-Checking Options */
+    "strict": true /* Enable all strict type-checking options. */,
+    // "noImplicitAny": true,                       /* Raise error on expressions and declarations with an implied 'any' type. */
+    // "strictNullChecks": true,                    /* Enable strict null checks. */
+    // "strictFunctionTypes": true,                 /* Enable strict checking of function types. */
+    // "strictBindCallApply": true,                 /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
+    // "strictPropertyInitialization": true,        /* Enable strict checking of property initialization in classes. */
+    // "noImplicitThis": true,                      /* Raise error on 'this' expressions with an implied 'any' type. */
+    // "alwaysStrict": true,                        /* Parse in strict mode and emit "use strict" for each source file. */
+
+    /* Additional Checks */
+    "noUnusedLocals": true /* Report errors on unused locals. */,
+    // "noUnusedParameters": true,                  /* Report errors on unused parameters. */
+    // "noImplicitReturns": true,                   /* Report error when not all code paths in function return a value. */
+    "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
+    // "noUncheckedIndexedAccess": true,            /* Include 'undefined' in index signature results */
+    "noImplicitOverride": true /* Ensure overriding members in derived classes are marked with an 'override' modifier. */,
+    // "noPropertyAccessFromIndexSignature": true,  /* Require undeclared properties from index signatures to use element accesses. */
+
+    /* Module Resolution Options */
+    "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
+    // "baseUrl": "./",                             /* Base directory to resolve non-absolute module names. */
+    // "paths": {},                                 /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
+    // "rootDirs": [],                              /* List of root folders whose combined content represents the structure of the project at runtime. */
+    // "typeRoots": [],                             /* List of folders to include type definitions from. */
+    // "types": [],                                 /* Type declaration files to be included in compilation. */
+    "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */,
+    "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
+    // "preserveSymlinks": true,                    /* Do not resolve the real path of symlinks. */
+    // "allowUmdGlobalAccess": true,                /* Allow accessing UMD globals from modules. */
+
+    /* Source Map Options */
+    // "sourceRoot": "",                            /* Specify the location where debugger should locate TypeScript files instead of source locations. */
+    // "mapRoot": "",                               /* Specify the location where debugger should locate map files instead of generated locations. */
+    // "inlineSourceMap": true,                     /* Emit a single file with source maps instead of having a separate file. */
+    // "inlineSources": true,                       /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
+
+    /* Experimental Options */
+    // "experimentalDecorators": true,              /* Enables experimental support for ES7 decorators. */
+    // "emitDecoratorMetadata": true,               /* Enables experimental support for emitting type metadata for decorators. */
+
+    /* Advanced Options */
+    "skipLibCheck": true /* Skip type checking of declaration files. */,
+    "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
+  },
+  "exclude": ["dist", "node_modules", "*.d.ts"]
+}

+ 2 - 0
packages/utils/src/index.ts

@@ -1,4 +1,6 @@
 export * from "./eventBus";
 export * from "./base64";
 export * from "./date";
+export * from "./string";
 export * from "./query-string";
+export * from "./noop";

+ 8 - 0
packages/utils/src/noop.ts

@@ -0,0 +1,8 @@
+export const NoopObject = {};
+export const NoopArray = [];
+export const Noop = () => {};
+
+if (process.env.NODE_ENV === "development") {
+  Object.freeze(NoopObject);
+  Object.freeze(NoopArray);
+}

+ 20 - 0
packages/utils/src/string.ts

@@ -0,0 +1,20 @@
+/**
+ * 字符串`末尾`添加`/`
+ */
+export function addTrailingSlash(path: string) {
+  return path.endsWith("/") ? path : path + "/";
+}
+
+/**
+ * 删除特定`开头`的`字符串`
+ */
+export function removeHeadingString(path: string, heading: string) {
+  return path.startsWith(heading) ? path.slice(heading.length) : path;
+}
+
+/**
+ * 删除字符串`开头`的`/`
+ */
+export function removeHeadingSlash(path: string) {
+  return removeHeadingString(path, "/");
+}

文件差异内容过多而无法显示
+ 634 - 288
pnpm-lock.yaml


+ 4 - 0
scripts/jest-env-setup.js

@@ -0,0 +1,4 @@
+import "reflect-metadata";
+import fetch from "isomorphic-fetch";
+
+window.fetch = fetch;