Swift 签名 SDK 接入指南

功能说明

Swift 签名 SDK 用于为美图开放平台 API 请求生成以下认证信息:

  • X-Sdk-Date:UTC 请求时间。
  • Authorization:基于 AK/SK 和请求内容生成的 HMAC-SHA256 签名。

SDK 返回包含 HTTP 方法、请求头和 body 的 URLRequest,可直接通过 URLSession 发送。

SK 只能保存在服务端,不可写入会发布给用户的 Apple 客户端。

环境要求

  • Apple 平台 Swift 工程。
  • 完整示例使用 Swift 5.5 或更高版本、macOS 12 或更高版本。
  • 依赖 FoundationCommonCrypto;SDK 1.0.0 不使用 CryptoKit。
  • 服务端系统时间需要保持准确,签名时间使用 UTC。

下载与引入

下载 Swift SDK 1.0.0

解压后包含:

AIGCP-API-swift-sdk-1.0.0/
├── MTSigner.swift
└── MTDemo.swift

MTSigner.swift 加入服务端 Swift target,并确认该文件属于正确的 Target Membership。MTDemo.swift 是独立 Demo 入口,接入已有工程时无需引入。

接口说明

SDK 的公开常量和方法如下:

public let kBasicDateFormat = "yyyyMMdd'T'HHmmss'Z'"
public let kAlgorithm = "SDK-HMAC-SHA256"
public let kHeaderXDate = "X-Sdk-Date"
public let kHeaderHost = "Host"
public let kHeaderAuthorization = "Authorization"
public let kHeaderContentSha256 = "X-Sdk-Content-Sha256"

public class MTSigner {
    public init(key: String, secret: String)

    public func signRequest(
        url: URL,
        method: String,
        headers: [String: String],
        body: String
    ) throws -> URLRequest
}
参数说明
key应用的 AK
secret与 AK 配套的 SK
url最终请求 URL,包含已编码并排序的查询参数
methodHTTP 方法,使用接口要求的大写值,如 GETPOST
headers参与签名的请求头;必须包含 Host
body实际发送的 UTF-8 字符串;GET 请求传空字符串
返回值已添加 X-Sdk-DateAuthorization 和请求 body 的 URLRequest

headers 已包含 X-Sdk-Date,SDK 使用该值;否则自动生成 UTC 时间。传入的所有请求头都会参与签名,签名完成后不得更改。

POST 完整调用示例

以下示例调用正式同步任务提交地址:

https://openapi.meitu.com/api/v1/sdk/sync/push

运行前设置 MEITU_OPENAPI_AKMEITU_OPENAPI_SKMEITU_TASKMEITU_IMAGE_URLMEITU_TASK、图片结构、paramstask_typesync_timeout 的取值以目标能力 API 文档为准。

请求 body 包含且仅包含五个顶层字段:tasktask_typeinit_imagesparamssync_timeout。其中 params 必须是 JSON 字符串。

import Foundation

enum IntegrationError: LocalizedError {
    case missingEnvironment(String)
    case invalidURL(String)
    case invalidUTF8Body
    case invalidHTTPResponse

    var errorDescription: String? {
        switch self {
        case .missingEnvironment(let name):
            return "Missing environment variable: \(name)"
        case .invalidURL(let value):
            return "Invalid URL: \(value)"
        case .invalidUTF8Body:
            return "Failed to create a stable UTF-8 request body"
        case .invalidHTTPResponse:
            return "The server did not return an HTTP response"
        }
    }
}

func requiredEnvironment(_ name: String) throws -> String {
    guard let value = ProcessInfo.processInfo.environment[name], !value.isEmpty else {
        throw IntegrationError.missingEnvironment(name)
    }
    return value
}

@main
struct PostExample {
    static func main() async {
        do {
            let accessKey = try requiredEnvironment("MEITU_OPENAPI_AK")
            let secretKey = try requiredEnvironment("MEITU_OPENAPI_SK")
            let taskName = try requiredEnvironment("MEITU_TASK")
            let imageURL = try requiredEnvironment("MEITU_IMAGE_URL")

            guard URL(string: imageURL) != nil else {
                throw IntegrationError.invalidURL(imageURL)
            }

            let endpoint = "https://openapi.meitu.com/api/v1/sdk/sync/push"
            guard let url = URL(string: endpoint) else {
                throw IntegrationError.invalidURL(endpoint)
            }

            let paramsObject: [String: Any] = [
                "parameter": ["rsp_media_type": "url"]
            ]
            let paramsData = try JSONSerialization.data(withJSONObject: paramsObject)
            guard let params = String(data: paramsData, encoding: .utf8) else {
                throw IntegrationError.invalidUTF8Body
            }

            let payload: [String: Any] = [
                "task": taskName,
                "task_type": "formula",
                "init_images": [[
                    "url": imageURL,
                    "profile": [
                        "media_profiles": ["media_data_type": "url"],
                        "version": "v1"
                    ]
                ]],
                "params": params,
                "sync_timeout": 30
            ]

            let bodyData = try JSONSerialization.data(withJSONObject: payload)
            guard let body = String(data: bodyData, encoding: .utf8),
                  body.data(using: .utf8) == bodyData else {
                throw IntegrationError.invalidUTF8Body
            }

            let headers: [String: String] = [
                kHeaderHost: "openapi.meitu.com",
                "Content-Type": "application/json; charset=utf-8",
                "Accept": "application/json"
            ]

            let signer = MTSigner(key: accessKey, secret: secretKey)
            let request = try signer.signRequest(
                url: url,
                method: "POST",
                headers: headers,
                body: body
            )

            guard request.httpBody == bodyData else {
                throw IntegrationError.invalidUTF8Body
            }

            let configuration = URLSessionConfiguration.ephemeral
            configuration.timeoutIntervalForRequest = 30
            configuration.timeoutIntervalForResource = 60
            let session = URLSession(configuration: configuration)
            let (responseData, response) = try await session.data(for: request)

            guard let httpResponse = response as? HTTPURLResponse else {
                throw IntegrationError.invalidHTTPResponse
            }

            let responseBody = String(data: responseData, encoding: .utf8) ?? ""
            print("HTTP \(httpResponse.statusCode)")
            print(responseBody)
        } catch {
            print("Request failed: \(error.localizedDescription)")
        }
    }
}

代码只序列化一次业务 payload,并直接发送签名器返回的 URLRequest。因此参与签名和实际发送的是同一份 UTF-8 body Data

GET 调用示例

GET 请求传空 body。urlString 必须替换为目标接口的最终 HTTPS URL;若包含多个查询参数,应在签名前完成 RFC 3986 编码和排序。

import Foundation

enum GETExampleError: Error {
    case invalidURL
    case invalidHTTPResponse
}

func callSignedGET(
    accessKey: String,
    secretKey: String,
    urlString: String
) async throws -> (statusCode: Int, body: Data) {
    guard let url = URL(string: urlString),
          url.scheme == "https",
          url.host == "openapi.meitu.com" else {
        throw GETExampleError.invalidURL
    }

    let headers: [String: String] = [
        kHeaderHost: "openapi.meitu.com",
        "Accept": "application/json"
    ]

    let signer = MTSigner(key: accessKey, secret: secretKey)
    let request = try signer.signRequest(
        url: url,
        method: "GET",
        headers: headers,
        body: ""
    )

    let (data, response) = try await URLSession.shared.data(for: request)
    guard let httpResponse = response as? HTTPURLResponse else {
        throw GETExampleError.invalidHTTPResponse
    }
    return (httpResponse.statusCode, data)
}

注意事项

  • 使用 HTTPS,并把 Host 设置为 openapi.meitu.com
  • 方法、URL、查询串、已签请求头和 body 在签名后必须保持不变;直接发送 signRequest 返回的请求。
  • JSON 只能序列化一次。不要在签名后调整字段、空格、换行或字符编码。
  • 查询参数需要先按接口签名规范完成百分比编码和排序,再构造最终 URL。
  • X-Sdk-Date 必须是 UTC yyyyMMdd'T'HHmmss'Z',并保持服务端时钟同步。
  • 默认对 UTF-8 body 计算 SHA-256。只有目标接口明确支持时,才可在签名前加入:
var headers: [String: String] = [
    kHeaderHost: "openapi.meitu.com",
    "Content-Type": "application/json; charset=utf-8",
    kHeaderContentSha256: "UNSIGNED-PAYLOAD"
]
  • AK/SK 认证请求的 body 应控制在 12 MB 以内。

常见错误

错误现象处理方式
no such module 'CommonCrypto'使用 Apple 平台 Xcode/Swift 工具链,并确认 MTSigner.swift 属于正确 target
401403 或签名校验失败检查 AK/SK、服务端时间、HTTP 方法、Host、最终 URL、查询串、请求头和 body 是否一致
POST 验签失败确认只序列化一次 JSON,并直接发送 SDK 返回的 URLRequest
GET 验签失败确认查询参数已编码和排序,且签名后没有修改 URL
UNSIGNED-PAYLOAD 不生效确认目标接口支持该方式,并使用精确的请求头名称和值
请求超时根据目标能力文档设置 sync_timeout,同时保证 URLSession 超时大于接口处理时间
返回非 2xx记录脱敏后的状态码和响应 body,并按目标能力 API 文档中的业务错误码处理