---
url: /v6.3/connecting-ext-api.md
---
# API 数据源

当用户使用 EXT API 数据时，可以通过 API 数据源将数据接入到衡石系统。 API 数据源定义统一的数据接入规范，能支持大量的无数据计算能力的 API 数据源。内置多种 API 数据源的同时支持用户自定义开发方式加入企业内部 API 数据源或其他公有 API 数据源，快速读取 API 数据源。API 数据源轻松实现接入外部 API 数据由1到 N 的快速增长，增强衡石数据的整合能力。

![](/images/data-connection/connecting-ext-api-001.png)

## 内置 API 数据源

### 明道云数据源

用户在明道云上的应用数据通过简单的配置就可以连接到衡石系统。配置步骤如下：

1. 在新建数据连接中选择 API 数据源。
   ![](/images/data-connection/connecting-ext-api-001.png)

2. 输入新建连接名称，API name 选择`mingdaoyun`。
   ![](/images/data-connection/connecting-ext-api-mingdao.png)

3. 填写明道云配置信息 API Config，示例如下:

   ```json
   {
     "appKey":"ab1axxxxxxxx",
     "secretKey":"68b7eqbp7r1p5ZeAbw60aXcG0h8Gxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
     "sign":"NWRiODkxZmQ0NzljNzcxZDhiMDZjMzE5ZDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
     "apiUrl": "https://api.mingdao.com/v2/open"
   }
   ```

   **明道云 API Config 配置说明**

   | 字段      | 是否必填 | 取值说明                                                     |
   | --------- | -------- | ------------------------------------------------------------ |
   | appKey    | 是       | 明道云应用中的 AppKey                                        |
   | secretKey | 是       | 明道云应用中的 secretKey                                     |
   | sign      | 是       | 明道云应用中的 sign                                          |
   | apiUrl    | 否       | 明道云为私有部署时，apiUrl 请填写私有部署地址；公有部署时 apiUrl 可置空或填写明道云公有地址 |

4. 验证成功后，点击保存，数据连接创建完成。在系统中可以查看和使用明道云中的数据。
   ![](/images/data-connection/connecting-ext-api-mingdao-path-data.png)

### FTP 数据源

系统支持 FTP 数据源，在 HENGSHI SENSE 中通过简单配置，可以读取 ftp 指定路径下的文件。 配置步骤如下。

1. 在新建数据连接中选择 API 数据源。
   ![](/images/data-connection/connecting-ext-api-ftp.png)
2. 输入新建连接名称、，API name 选择 `ftp`
3. 填写FTP的 API Config配置信息，示例如下:

   ```json
   {
    "host":"10.10.x.y",
    "username":"xxxxxxx",
    "password":"xxxxxxx",
    "directory":"/opt/ftp",
    "encoding":"utf8",
    "passive":true,
   }
   ```

   **FTP数据源的 API Config 配置说明**

   | 字段      | 是否必填 | 取值说明        |
   | --------- | -------- | --------------- |
   | host      | 是       | ftp服务器的地址 |
   | username  | 是       | ftp登录名       |
   | password  | 是       | ftp登录密码     |
   | directory | 否       | ftp映射目录     |
   | encoding  | 否       | 编码            |
   | passive   | 否       |                 |
4. 验证成功后，点击保存，数据连接创建完成。在系统中可以查看和使用连接数据。
   ![](/images/data-connection/connecting-ext-api-ftp-path-data.png)

> \[!TIP] 提示
>
> 1. FTP 数据源只能将 csv 和 excel 解析为表的形式，其他类型的文件不支持解析。
>
> 2. FTP数据源中, excel格式的只能在新建数据集后预览数据，不支持直接在 数据连接 中预览。

### 其他内置数据源

除了上述 FTP 和明道云外，系统还支持 `S3`、`旺店通`、`企业微信`、`石墨文档`、`聚水潭`、`易仓科技`、`Wai Qin`等 API 数据源，用户配置 API config 后，即可将数据接入到衡石系统中。

![](/images/data-connection/connecting-ext-api-apiName.png)

#### `API Config`配置示例

下面是支持的几个内置API数据源的 `API Config`的配置示例:

**S3数据源的`API Config`**

```json
{
    "access_key_id": "AKIAWBC5HCDHELXXXXXXXX",
    "secret_key_id": "67U4ZvclZxkqiM9owAVTEootwyxxxxxxxx",
    "region": "cn-north-x",
    "useVirtualFolderDelimiter": false,
    "bucket": "",
    "path": ""
}
```

**旺店通 数据源的`API Config`**

```json
{
    "appsecret": "3a5737fc6bc2d6970f790308xxxxxxxx",
    "sid": "xxxxxxxx",
    "appkey": "xxxxxxxx-ot",
    "env": "production",
    "customizedStartTime": "2022-01-01 00:00:00",
    "addHSTimeColumn": true,
    "tableConfig": {
        "/stockin_order_query.php": {
            "timeWindow": 60,
            "timeWindowLimit": 50
        }
    }
}
```

**企业微信 数据源的`API Config`**

```json
{
    "corpid": "wwcXXXXXXXXXX",
    "corpsecret": "XGGBxxxxxxxxxxxxxxxxxxxxxxxx",
    "customizedStartTime": "2022-09-01 00:00:00",
    "addHSTimeColumn": false,
    "templates": [
        "ZLyhvnbAYmJnwLXyyxxxxxxxxxxxxxxxxxxxx1",
        "ZLyhvnbAYmJnwLXyyxxxxxxxxxxxxxxxxxxxx2"
    ]
}
```

**石墨 数据源的`API Config`**

```json
{
    "sid": "s%3A7db3e9d7bab34969a6d592xxxxxxxxxxxx.hnmK3aq2yOYYJB9exxxxxxx",
    "addHSTimeColumn": true,
    "customCacheMaxLifetime": 5
}
```

**聚水潭 数据源的`API Config`**

```json
{
    "app_key": "b0b7d1db226d4216xxxxxxxxxxxxxxxx",
    "app_secret": "99c4cef262f34ca88xxxxxxxxxxxx",
    "access_token": "b7e3b1e24e174593xxxxxxxxxxx",
    "env": "test",
    "customizedStartTime": "2022-11-15 00:00:00",
    "addHSTimeColumn": true,
    "tableConfig": {
        "/open/orders/single/query": {
            "timeWindow": 30,
            "timeWindowLimit": 3
        }
    }
}
```

**易仓科技 数据源的`API Config`**

```json
{
    "app_key": "e4bd91dcxxxxxxxx",
    "service_id": "xxxxxxxx",
    "secret_key": "bab4d5xxxxxxxx"
}
```

**Wai Qin 数据源的`API Config`**

```json
{
   "openId": "76776371xxxxxxxx",
   "appKey": "u7To2bdLxxxxxxxx",
   "customFormList": [
       "74842108646xxxxxxxx1",
       "74842108646xxxxxxxx2"
   ],
   "aiProductIdentificationResultQueryStartTime": "2025-01-01 00:00:00"
}
```

## 指标API数据源

指标API数据源把第三方指标平台接入 HENGSHI SENSE，供[动态主题域](business-indicator-domain.md#新建动态主题域)读取指标和维度，并在应用仪表盘中查询指标数据。该连接不提供数据库表目录，也不能用于创建普通数据集。

### 创建指标API数据源

1. 进入`数据连接`，点击`新建数据连接`，选择`指标API数据源`。
2. 输入连接名称。如果 Groovy 脚本需要连接参数，在`API Config`中填写第三方平台地址、接口路径和认证信息；脚本不需要配置时可留空或填写`{}`。
3. 将适配第三方平台的 Groovy 脚本填入`Custom Script`。
4. 点击`验证`。验证通过后保存连接。
5. 如需将连接提供给其他用户，在连接的三点菜单中配置`指标查看权限`。

> \[!CAUTION] 注意
> 页面填写 Groovy 脚本需要先启用 `ENABLE_GROOVY_SCRIPT`，并重启服务使配置生效。服务器通过 `EXT_API_PATH` 管理的脚本不受该开关影响。脚本会执行外部 HTTP 请求，应限制目标地址、校验证书，并避免在日志中输出认证信息和完整响应。

`API Config`由 Groovy 脚本解析，字段可以按第三方平台的接口约定调整。下面是一个不含真实地址和凭据的示例：

```json
{
  "baseUrl": "https://metrics.example.com",
  "metricsPath": "/api/metrics",
  "analysisContextPath": "/api/metrics/context",
  "dataPath": "/api/metrics/query",
  "token": "<metric-platform-token>"
}
```

请勿将真实凭据写入共享文档、脚本注释或调试日志。

### Groovy 脚本接口

指标API数据源使用独立的脚本接口，不使用普通自定义 API 数据源的 `getApiName`、`getPathTables` 和 `fetchTableData`。脚本按需实现以下方法：

| 方法 | 用途 |
| --- | --- |
| `setOptions(String)` | 解析`API Config` |
| `fetchMetrics()` / `fetchMetrics(JSONObject)` | 获取指标和维度列表 |
| `fetchMetricsPage(JSONObject)` | 获取带分页总数的指标和维度列表 |
| `queryMeasureAnalysisContext(JSONObject)` | 返回候选指标、兼容指标和可分析维度 |
| `buildChartRequest(options)` | 将图表配置转换为第三方平台的查询请求 |
| `fetchData(JSONObject)` | 返回图表字段结构和数据行 |
| `fetchFunctions()` | 返回第三方平台支持的计算函数，可省略 |
| `testConnection()` | 验证地址、认证和指标接口，可省略；省略时系统调用`fetchMetrics()`验证 |

下面的脱敏示例假设第三方接口返回统一的 `data` 对象：指标列表使用 `metrics`、`dimensions` 和 `total`，分析上下文使用 `candidateMetrics`、`metrics`、`unionDims` 和 `dims`，查数结果使用 `schema`、`rows` 和 `total`。实际字段不同时，请在脚本中完成映射。

```groovy
import com.alibaba.fastjson.JSON
import com.alibaba.fastjson.JSONArray
import com.alibaba.fastjson.JSONObject
import com.hengshi.nangaparbat.dto.PageDto
import com.hengshi.nangaparbat.model.DatasetField
import com.hengshi.nangaparbat.model.Type

import java.net.HttpURLConnection
import java.net.URL

class MetricApiProviderExample {
    private JSONObject config = new JSONObject()

    def setOptions(String optionsText) {
        config = JSON.parseObject(optionsText ?: "{}") ?: new JSONObject()
    }

    def fetchMetrics() {
        return fetchMetricsPage(null).data
    }

    def fetchMetrics(JSONObject request) {
        return fetchMetricsPage(request).data
    }

    def fetchMetricsPage(JSONObject request) {
        JSONObject data = responseData(postJson(
                config.getString("metricsPath") ?: "/api/metrics",
                request ?: new JSONObject()))

        def fields = []
        fields.addAll(toFields(data.getJSONArray("metrics"), DatasetField.CategoryEnum.measure))
        fields.addAll(toFields(data.getJSONArray("dimensions"), DatasetField.CategoryEnum.field))

        PageDto page = new PageDto()
        page.setData(fields)
        Long total = data.getLong("total")
        page.setTotalHits(total == null ? fields.size() : total)
        return page
    }

    def queryMeasureAnalysisContext(JSONObject request) {
        JSONObject data = responseData(postJson(
                config.getString("analysisContextPath") ?: "/api/metrics/context",
                request ?: new JSONObject()))

        return [
                candidateMeasures : toFieldMaps(data.getJSONArray("candidateMetrics"), "measure"),
                compatibleMeasures: toFieldMaps(data.getJSONArray("metrics"), "measure"),
                unionFields       : toFieldMaps(data.getJSONArray("unionDims"), "field"),
                intersectionFields: toFieldMaps(data.getJSONArray("dims"), "field")
        ]
    }

    def buildChartRequest(options) {
        // 第三方字段不同时，在此处转换维度、指标、过滤、排序和分页。
        return JSON.parseObject(JSON.toJSONString(options ?: [:]))
    }

    def fetchData(JSONObject request) {
        JSONObject data = responseData(postJson(
                config.getString("dataPath") ?: "/api/metrics/query",
                request ?: new JSONObject()))

        return JSON.toJSONString([
                schema   : toFieldMaps(data.getJSONArray("schema"), null),
                data     : data.getJSONArray("rows") ?: [],
                totalHits: data.getLong("total")
        ])
    }

    def fetchFunctions() {
        return ["sum", "avg", "countDistinct"]
    }

    def testConnection() {
        return fetchMetrics() != null
    }

    private JSONObject postJson(String path, Object body) {
        String baseUrl = config.getString("baseUrl")
        if (!baseUrl) {
            throw new IllegalArgumentException("baseUrl is required")
        }

        String url = baseUrl.replaceAll('/+$', '') + '/' + path.replaceAll('^/+', '')
        HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection()
        connection.setRequestMethod("POST")
        connection.setDoOutput(true)
        connection.setConnectTimeout(30000)
        connection.setReadTimeout(30000)
        connection.setRequestProperty("Content-Type", "application/json;charset=UTF-8")
        connection.setRequestProperty("Accept", "application/json")

        String token = config.getString("token")
        if (token) {
            connection.setRequestProperty("Authorization", "Bearer " + token)
        }

        connection.outputStream.withCloseable { output ->
            output.write(JSON.toJSONString(body ?: [:]).getBytes("UTF-8"))
        }

        int responseCode = connection.getResponseCode()
        def stream = responseCode >= 400 ? connection.getErrorStream() : connection.getInputStream()
        String responseText = stream == null ? "" : stream.getText("UTF-8")
        connection.disconnect()

        if (responseCode >= 400) {
            throw new IllegalStateException("Metric API request failed: HTTP " + responseCode)
        }
        return JSON.parseObject(responseText)
    }

    private JSONObject responseData(JSONObject response) {
        return response?.getJSONObject("data") ?: response ?: new JSONObject()
    }

    private List<DatasetField> toFields(JSONArray items, DatasetField.CategoryEnum category) {
        return (items ?: []).collect { JSONObject item ->
            DatasetField field = new DatasetField()
            field.setFieldName(item.getString("code") ?: item.getString("fieldName"))
            field.setLabel(item.getString("name") ?: item.getString("label"))
            field.setCategory(category)
            field.setType(toType(item.getString("type")))
            return field
        }
    }

    private List<Map> toFieldMaps(JSONArray items, String category) {
        return (items ?: []).collect { JSONObject item ->
            def field = [
                    fieldName: item.getString("code") ?: item.getString("fieldName"),
                    label    : item.getString("name") ?: item.getString("label"),
                    type     : toType(item.getString("type")).name()
            ]
            if (category) {
                field.category = category
            }
            return field
        }
    }

    private Type.TypeName toType(String rawType) {
        switch ((rawType ?: "string").toLowerCase()) {
            case ["number", "integer", "long", "decimal", "double", "float"]:
                return Type.TypeName.number
            case ["date", "datetime", "timestamp", "time"]:
                return Type.TypeName.date
            case ["boolean", "bool"]:
                return Type.TypeName.bool
            case "json":
                return Type.TypeName.json
            default:
                return Type.TypeName.string
        }
    }
}
```

## 自定义 API 数据源

> \[!CAUTION] 注意
> 从 5.1 版本开始，为了安全性，系统默认**关闭**页面上填写的 Groovy 脚本功能。
> 如需使用自定义 API 数据源（通过页面填写 Groovy 脚本），需要在配置文件中添加：
>
> ```properties
> ENABLE_GROOVY_SCRIPT=true
> ```
>
> 配置完成后需重启服务生效。上传到服务器的 Groovy 脚本（通过 `EXT_API_PATH` 变量配置）不受影响。

除了内置的 API 数据源外，系统支持用户自定义 API 数据源，可以使用groovy脚本实现自定义 API 数据源。

1. 在新建数据连接中选择 API 数据源。
   ![](/images/data-connection/connecting-ext-api-groovy.png)
2. 输入新建连接名称、，API name 选择 `自定义`
3. 填写 API Config配置信息，**注意: 这里的 配置信息需要在脚本中通过代码解析**。不需要配置时,建议填空`{}`
4. 按照规范编写自定义 API 数据源的 Groovy 脚本，放到 `Custom Script`

![](/images/data-connection/connecting-ext-api-groovy-script.png)

## 自定义API 数据源脚本规范

自定义数据源接入时，需要在 Groovy 脚本中实现下面四个接口。

* getAPIName ： 用来获取 API 名称。
* setOptions：用来处理认证鉴权相关配置信息。
* fetchPathTables：用户获取数据源目录结构。
* fetchTableData ： 用于获取 table 节点数据。

##### 函数 getAPIName

函数 getAPIName 的作用是输出 API 名称。 每一个 API 数据源名称不能相同，如果相同会导致发生冲突。
该函数是类 static 函数，返回值为 string 类型的字符串。

```groovy
		static getApiName() {
        return "TrivalApiDemo"
    }
```

##### 函数 setOptions

函数 setOptions 的作用是用来保存处理认证鉴权相关的配置信息。  如果不涉及外部网络请求，可以直接留空了。
该函数是 object 函数， 函数参数是一个 JSON 字符串，这个字符串是用户在添加数据连接时填写的信息。

```groovy
    def setOptions(String options) {
        if (!options?.trim()) {
            return
        }
        def json = jsonSlurper.parseText(options)
        if (json.cityCodeURL?.trim()) {
            config.cityCodeURL = JSON.cityCodeURL
        }
        if (json.weatherURL?.trim()) {
            config.weatherURL = JSON.weatherURL
        }
    }
```

##### 函数 getPathTables

函数 getPathTables 的作用是返回目录树，把该数据源的所有 catalog/schema/table 的结构都返回。 代码示例如下。

```groovy
  def getPathTables() {
      def tableTree = [
           [ pathType: "table", name: "table1", tableType: "table"],
           [ pathType: "path", name: "schema2", 
              children: [
                  [ pathType: "table", name: "table3", tableType: "view"],
                  [ pathType: "table", name: "table4", tableType: "table", remarks: "表别名4"]
              ]
          ]
      ]
      return new JsonBuilder(tableTree).toString()
  }
```

结构定义说明：

* pathType 的种类有 table(代表叶子结点的表), path(代表数据库中的 schema 或者 catalog)
* name 就是 表名或者 schema/catalog 的名字
* tableType 只对 table 有意义，它的类型一般写成 table 就可以了(另外一个选项是 view，不常用)
* 当 pathType 为 path 时，代表这个节点不是一个表，而是一个 schema 或者 catalog 这样的中间目录结构，此时它可以有 children 数组节点
* remarks 是当前节点的一个说明，需要的时候可以提供，是可选项。

通过上面的函数定义的 schema， 可以在数据连接中获取 table。

##### 函数 fetchTableData

函数 fetchTableData 函数的作用是获取一个叶子节点，即 table 节点的数据。代码示例如下。

```groovy
  def fetchTableData(List<String> tablePath) {
      def tableName = tablePath.last()
      def schemaData = [
          schema: [
              [fieldName: "field1", label: "字段别名1", type: "string"],
              [fieldName: "field2", label: null, type: "string"],
              [fieldName: "field3", type: "number"],
              [fieldName: "field4", type: "date"],
              [fieldName: "field5", type: "time"]],
          data: [
              ["a", "b", 10, "2022-01-01", "2022-01-01 23:59:59"],
              ["c", "d", 20.20, "2019-12-39", "2019-12-39 23:59:59"]
          ]
        ]
      return new JsonBuilder(schemaData).toString()
  }
```

Schema 结构定义：

* fieldName 字段名，需要在 table 内具有唯一性，能区分不同的字段。
* type：字段的类型，常用的类型有：string, number, date, time, json, bool。
* label：字段的别名。

Data 结构定义：

* data 的每一个元素都是一个数组，数组的元素个数必须和 schema 相同，数据的类型也必须一一匹配。

## 自定义API 数据源代码示例

下面使用 Java 构造一个天气 API 自定义数据源。weather.groovy 文件定义内容如下：

```groovy
//Weather.groovy
import com.hengshi.nangaparbat.dto.DatasetResultDto
import com.hengshi.nangaparbat.model.PathTableNode
import static com.hengshi.nangaparbat.model.Dataset.Field
import static com.hengshi.nangaparbat.model.Type.TypeName
import groovy.json.JsonSlurper

class Provider {
    static getApiName() {
        return "天气 API"
    }

    def setOptions(String options) {
        if (!options?.trim()) {
            return
        }
        def json = jsonSlurper.parseText(options)
        if (json.cityCodeURL?.trim()) {
            config.cityCodeURL = json.cityCodeURL
        }
        if (json.weatherURL?.trim()) {
            config.weatherURL = json.weatherURL
        }
    }

    def getPathTables() {
        def txt = config.cityCodeURL.toURL().text
        def arr = jsonSlurper.parseText(txt)
        return arr.collect{ obj ->
            def node = PathTableNode.createTableNode(obj.city_code)
            node.setRemarks(obj.city_name)
            return node
        }
    }

    def fetchTableData(List<String> tablePath) {
        def url = config.weatherURL + tablePath.last()
        def txt = url.toURL().text;
        def result = new DatasetResultDto()
        def schema = []
        def data = []
        def row = []
        def arr = jsonSlurper.parseText(txt)
        arr.data.each{ k, v ->
            def field = new Field();
            field.setFieldName(k)
            field.setType(TypeName.string)
            if (k.startsWith("pm")) {
                field.setType(TypeName.integer)
            }
            schema.add(field)
            row.add(v);
        }
        result.setSchema(schema)
        data.add(row)
        result.setData(data)
        return result
    }

    def jsonSlurper = new JsonSlurper()

    def config = [
        cityCodeURL: "https://github.com/baichengzhou/weather.api/raw/master/src/main/resources/citycode-2019-08-23.json",
        weatherURL: "http://t.weather.itboy.net/api/weather/city/"
    ]
}
```

代码说明：

* DatasetResultDto 定义了 fetchTableData 的返回结果，它由 schema 和 data 组成，通过 setData 和 setSchema 来写入。
* PathTableNode 定义了 getPathTables 的返回，它由 pathType, name, children 和 tableType 组成，也是通过各自的 setter 来写入
* schema 由多个 Field 组成，Field 由 fieldName, type 和 label 组成。
* type 的种类是一个枚举类型定义的 TypeName。
* [CityCode](https://github.com/baichengzhou/weather.api/raw/master/src/main/resources/citycode-2019-08-23.json)中定义了目录结构。
* [天气网站](http://t.weather.itboy.net/api/weather/city/101120201)是天气数据来源，记录了各个城市的天气数据。
