接入外部 OpenAPI 与 API Key 管理

新增外部编辑器 OpenAPI 路由与 openapi.json 导出

新增账号级 API Key 表、鉴权、创建、列表和撤销链路

新增个人中心开发者 API Key 管理弹窗

补充前端契约、客户端方法、测试与项目文档
This commit is contained in:
2026-06-19 15:29:30 +08:00
parent cc3346758c
commit 95d6bc2ae7
40 changed files with 3593 additions and 57 deletions
File diff suppressed because it is too large Load Diff
@@ -16,6 +16,14 @@
---
## 2026-06-19 外部 OpenAPI 与 API Key 管理走 server-rs 正式链路
- 背景:外部调用方需要稳定调用图片画布项目创建、画布布局保存和编辑器美术生图能力,同时需要可撤销的开发者凭据,不能依赖前端临时状态或人工分发密钥。
- 决策:外部 API 固定放在 `/api/external/v1` 命名空间,v1 暴露项目创建 / 读取、默认画布保存、编辑器美术生图和 `/api/external/v1/openapi.json`。API Key 管理走登录态 `/api/profile/api-keys`,外部调用使用 `Authorization: Bearer tnr_sk_xxx`;后端只保存 `key_hash``key_prefix`,明文只在创建响应返回一次。外部 API 鉴权、项目 / 画布 / 素材写回全部经 `api-server -> spacetime-client -> spacetime-module`,生成图片成功后同时写入账号级 `editor_asset`,带 `projectId` 时写入 `editor_project_resource`
- 影响范围:`server-rs/crates/api-server/src/external_*``server-rs/crates/api-server/src/modules/external_api.rs``server-rs/crates/spacetime-module/src/external_api_key_storage.rs``server-rs/crates/spacetime-client/src/external_api_key.rs``docs/openapi/genarrative-external-v1.openapi.json` 和后端数据契约文档。
- 验证方式:`cargo test -p api-server external_api --manifest-path server-rs/Cargo.toml``cargo test -p api-server external_editor_api --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:spacetime-schema``npm run check:encoding``git diff --check`
- 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`
## 2026-06-19 图片画布生成按钮价格统一绑定模型定价配置
- 背景:图片画布的生成图片、生成视频、生成规范、生成角色、生成素材、生成 UI、宣发素材、快速编辑、重绘和音频生成入口都在按钮内显示泥点;如果按钮文案、前端提交和后端校验各自写固定数值,后续调整模型价格会出现展示价、提交价和扣费价不一致。
@@ -429,6 +429,13 @@ npm run check:server-rs-ddd
- Rust 结构体:`DatabaseMigrationOperator`
- 源码:`server-rs/crates/spacetime-module/src/migration.rs`
### `external_api_key`
- Rust 结构体:`ExternalApiKey`
- 源码:`server-rs/crates/spacetime-module/src/external_api_key_storage.rs`
- 说明:外部 OpenAPI 调用使用的账号级 API Key 凭据表,只保存 key prefix、SHA-256 hash、作用域、撤销状态和使用时间;明文 Key 只在 `/api/profile/api-keys` 创建接口返回一次,不进入 SpacetimeDB。
- 索引:`by_external_api_key_owner_user_id` 用于登录态 API Key 列表;`key_hash` 唯一索引用于外部 API 鉴权。
### `editor_project`
- Rust 结构体:`EditorProject`
@@ -0,0 +1,101 @@
# 外部 OpenAPI 与 API Key 接入方案
## 背景
外部调用方需要通过稳定 HTTP 契约使用图片画布编辑器内的美术生图能力,并能创建项目、保存画板布局。该能力必须走 `server-rs + Axum + SpacetimeDB` 正式链路,不能把 API Key、画板状态或生成结果放到前端临时状态中。
## v1 范围
本期新增外部 API 命名空间:
```text
/api/external/v1
```
v1 只开放以下能力:
- `POST /api/external/v1/editor/projects`:创建图片画布项目。
- `GET /api/external/v1/editor/projects/{projectId}`:读取项目与默认画布。
- `PATCH /api/external/v1/editor/projects/{projectId}/canvas`:保存默认画布的 viewport 和 layers。
- `POST /api/external/v1/editor/images/generations`:调用编辑器美术生图能力;可选传入 `projectId`,生成后自动写入 `editor_project_resource`,同时写入账号级 `editor_asset` 素材库。
- `GET /api/external/v1/openapi.json`:导出本版本 OpenAPI 3.1 JSON。
管理 API Key 的登录态接口:
```text
GET /api/profile/api-keys
POST /api/profile/api-keys
DELETE /api/profile/api-keys/{keyId}
```
前端入口位于登录后个人中心的 `我的 → 开发者 API Key`,用于查看当前 Key、创建新 Key、复制一次性明文和撤销已创建 Key。
## 鉴权
外部调用使用 Bearer API Key
```http
Authorization: Bearer tnr_sk_xxx
```
规则:
- API Key 归属于 `owner_user_id`,外部接口只能访问该账号自己的项目、画布和生成素材。
- 明文 Key 只在创建接口返回一次,后端只保存 `key_hash``key_prefix`
- API Key 被撤销后立即不可再用于外部接口。
- 外部 API 鉴权不复用登录态 JWT,不检查 refresh session;它是独立开发者凭据。
- OpenAPI JSON 公共可读,不需要鉴权。
## 数据模型
新增 SpacetimeDB private 表:
```text
external_api_key
```
字段:
- `key_id`:主键。
- `owner_user_id`:所属账号。
- `name`:用户可识别名称。
- `key_prefix`:前缀片段,用于列表展示和排障。
- `key_hash`:完整 Key 的 SHA-256 十六进制摘要,唯一。
- `scopes_json`:作用域 JSONv1 固定包含 `editor:project``editor:canvas``editor:image-generate`
- `created_at` / `last_used_at` / `revoked_at` / `updated_at`
SpacetimeDB procedure
- `create_external_api_key_and_return`
- `list_external_api_keys_and_return`
- `revoke_external_api_key_and_return`
- `authenticate_external_api_key_and_return`
## 生成图落库
外部生图接口复用编辑器内 `VectorEngine` / `gpt-image-2` 生成链路,后端拿到图片后:
1. 通过 OSS / asset object adapter 持久化图片。
2. 写入 `editor_asset`,让生成图进入账号级素材库。
3. 如果请求带 `projectId`,写入 `editor_project_resource`
4. 返回图片读取地址、素材 ID、资源 ID、尺寸、prompt、model、provider 和 taskId。
如果请求未带 `projectId`,只生成并写入素材库;调用方可随后创建项目或自行保存画板布局。
## OpenAPI 导出
OpenAPI 3.1 JSON 固定落在:
```text
docs/openapi/genarrative-external-v1.openapi.json
```
服务端 `GET /api/external/v1/openapi.json` 使用同一份 JSON,通过 `include_str!` 导出,避免运行时生成结果与仓库文档漂移。
## 验收
- API Key 创建只返回一次明文,列表不返回明文。
- 撤销后的 API Key 调用外部接口返回 `401`
- 外部生图成功后,生成结果同时出现在画布资源和账号级素材库。
- OpenAPI JSON 能被 `serde_json` 解析,且 security scheme 为 Bearer API Key。
- 修改 SpacetimeDB schema 后运行 `npm run spacetime:generate``npm run check:spacetime-schema`
+24
View File
@@ -75,6 +75,30 @@ export type ProfileWalletLedgerResponse = {
entries: ProfileWalletLedgerEntry[];
};
export type ExternalApiKeyProfile = {
keyId: string;
name: string;
keyPrefix: string;
scopes: string[];
createdAt: string;
lastUsedAt: string | null;
revokedAt: string | null;
updatedAt: string;
};
export type ExternalApiKeyListResponse = {
keys: ExternalApiKeyProfile[];
};
export type ExternalApiKeyCreateResponse = {
apiKey: string;
key: ExternalApiKeyProfile;
};
export type ExternalApiKeyMutationResponse = {
key: ExternalApiKeyProfile;
};
export type ProfileRechargeProductKind = 'points' | 'membership';
export type ProfileMembershipStatus = 'normal' | 'active';
export type ProfileMembershipTier = 'normal' | 'month' | 'season' | 'year';
+1
View File
@@ -42,6 +42,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(modules::internal::router(state.clone()))
.merge(modules::auth::router(state.clone()))
.merge(modules::profile::router(state.clone()))
.merge(modules::external_api::router(state.clone()))
.merge(modules::assets::router(state.clone()))
.merge(modules::editor_project::router(state.clone()))
.merge(modules::platform::router(state.clone()))
@@ -54,12 +54,12 @@ use crate::{
state::AppState,
};
const EDITOR_PROJECT_ID_PREFIX: &str = "editor-project-";
const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-";
pub(crate) const EDITOR_PROJECT_ID_PREFIX: &str = "editor-project-";
pub(crate) const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-";
const EDITOR_ASSET_FOLDER_ID_PREFIX: &str = "editor-asset-folder-";
const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-";
pub(crate) const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-";
const EDITOR_LAYOUT_MAX_BYTES: usize = 256 * 1024;
const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布";
pub(crate) const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布";
const EDITOR_IMAGE_GENERATION_SIZE: &str = "1024x1024";
const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2";
@@ -88,8 +88,8 @@ pub struct EditorCanvasViewportPayload {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectLayoutSaveRequest {
viewport: EditorCanvasViewportPayload,
layers: Value,
pub(crate) viewport: EditorCanvasViewportPayload,
pub(crate) layers: Value,
}
#[derive(Debug, Deserialize)]
@@ -932,13 +932,16 @@ pub async fn generate_editor_image(
.unwrap_or((1024, 1024));
let persisted = if is_character_generation {
Some(
persist_editor_character_image(
persist_editor_generated_image(
&state,
authenticated.claims().user_id(),
generated.task_id.as_str(),
&image,
submitted_prompt.as_str(),
generated.actual_prompt.as_deref(),
EDITOR_CHARACTER_IMAGE_ASSET_KIND,
"character-images",
EDITOR_CHARACTER_IMAGE_SLOT,
)
.await?,
)
@@ -1345,7 +1348,9 @@ pub async fn extract_editor_ui_design_assets(
))
}
fn editor_project_payload_from_record(record: EditorProjectRecord) -> EditorProjectPayload {
pub(crate) fn editor_project_payload_from_record(
record: EditorProjectRecord,
) -> EditorProjectPayload {
let canvas = editor_canvas_payload_from_record(record.canvas);
EditorProjectPayload {
project_id: record.project_id,
@@ -1382,7 +1387,7 @@ fn editor_canvas_payload_from_record(record: EditorCanvasRecord) -> EditorCanvas
}
}
fn editor_project_resource_payload_from_record(
pub(crate) fn editor_project_resource_payload_from_record(
record: EditorProjectResourceRecord,
) -> EditorProjectResourcePayload {
EditorProjectResourcePayload {
@@ -1436,7 +1441,7 @@ fn editor_asset_folder_payload_from_record(
}
}
fn editor_asset_payload_from_record(record: EditorAssetRecord) -> EditorAssetPayload {
pub(crate) fn editor_asset_payload_from_record(record: EditorAssetRecord) -> EditorAssetPayload {
EditorAssetPayload {
asset_id: record.asset_id,
folder_id: record.folder_id,
@@ -1458,7 +1463,7 @@ fn editor_asset_payload_from_record(record: EditorAssetRecord) -> EditorAssetPay
}
impl EditorCanvasViewportPayload {
fn into_record(self) -> EditorCanvasViewportRecord {
pub(crate) fn into_record(self) -> EditorCanvasViewportRecord {
EditorCanvasViewportRecord {
x: self.x,
y: self.y,
@@ -1471,7 +1476,7 @@ fn current_owner_user_id(authenticated: &AuthenticatedAccessToken) -> String {
authenticated.claims().user_id().to_string()
}
fn serialize_editor_layers(layers: Value) -> Result<String, AppError> {
pub(crate) fn serialize_editor_layers(layers: Value) -> Result<String, AppError> {
let payload = serde_json::to_string(&layers).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-project",
@@ -1489,13 +1494,13 @@ fn serialize_editor_layers(layers: Value) -> Result<String, AppError> {
Ok(payload)
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
pub(crate) fn normalize_optional_string(value: Option<String>) -> Option<String> {
value
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
}
fn sanitize_editor_storage_segment(value: &str, fallback: &str) -> String {
pub(crate) fn sanitize_editor_storage_segment(value: &str, fallback: &str) -> String {
let normalized = value
.trim()
.chars()
@@ -1620,7 +1625,7 @@ fn prepare_editor_character_image_for_response(
}
}
fn data_url_from_image_bytes(mime_type: &str, bytes: &[u8]) -> String {
pub(crate) fn data_url_from_image_bytes(mime_type: &str, bytes: &[u8]) -> String {
format!(
"data:{};base64,{}",
mime_type,
@@ -1648,18 +1653,21 @@ struct EditorGenerationOptions {
provider_image_size: &'static str,
}
struct PersistedEditorGeneratedImage {
object_key: String,
asset_object_id: String,
pub(crate) struct PersistedEditorGeneratedImage {
pub(crate) object_key: String,
pub(crate) asset_object_id: String,
}
async fn persist_editor_character_image(
pub(crate) async fn persist_editor_generated_image(
state: &AppState,
owner_user_id: &str,
task_id: &str,
image: &DownloadedOpenAiImage,
prompt: &str,
actual_prompt: Option<&str>,
asset_kind: &str,
path_kind: &str,
slot: &str,
) -> Result<PersistedEditorGeneratedImage, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
@@ -1672,7 +1680,7 @@ async fn persist_editor_character_image(
prefix: LegacyAssetPrefix::CharacterDrafts,
path_segments: vec![
"editor".to_string(),
"character-images".to_string(),
sanitize_editor_storage_segment(path_kind, "generated-images"),
sanitize_editor_storage_segment(task_id, "task"),
],
file_stem: "image".to_string(),
@@ -1682,11 +1690,11 @@ async fn persist_editor_character_image(
},
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some(EDITOR_CHARACTER_IMAGE_ASSET_KIND.to_string()),
asset_kind: Some(asset_kind.to_string()),
owner_user_id: Some(owner_user_id.to_string()),
entity_kind: Some(EDITOR_CHARACTER_IMAGE_ENTITY_KIND.to_string()),
entity_id: Some(task_id.to_string()),
slot: Some(EDITOR_CHARACTER_IMAGE_SLOT.to_string()),
slot: Some(slot.to_string()),
provider: Some("vector-engine".to_string()),
task_id: Some(task_id.to_string()),
},
@@ -1728,7 +1736,7 @@ async fn persist_editor_character_image(
head.content_type.or(Some(persisted_mime_type)),
head.content_length,
Some(actual_prompt.unwrap_or(prompt).to_string()),
EDITOR_CHARACTER_IMAGE_ASSET_KIND.to_string(),
asset_kind.to_string(),
Some(task_id.to_string()),
Some(owner_user_id.to_string()),
None,
@@ -1751,7 +1759,7 @@ async fn persist_editor_character_image(
})
}
fn parse_editor_reference_image(source: &str) -> Result<OpenAiReferenceImage, AppError> {
pub(crate) fn parse_editor_reference_image(source: &str) -> Result<OpenAiReferenceImage, AppError> {
let Some((header, data)) = source.trim().split_once(',') else {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
@@ -1803,7 +1811,7 @@ fn parse_editor_reference_image(source: &str) -> Result<OpenAiReferenceImage, Ap
})
}
fn map_editor_project_error(error: SpacetimeClientError) -> AppError {
pub(crate) fn map_editor_project_error(error: SpacetimeClientError) -> AppError {
match error {
SpacetimeClientError::Procedure(message) if message.contains("无权") => {
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
@@ -1829,14 +1837,14 @@ fn map_editor_project_error(error: SpacetimeClientError) -> AppError {
}
}
fn map_editor_asset_field_error(error: AssetObjectFieldError) -> AppError {
pub(crate) fn map_editor_asset_field_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"message": error.to_string(),
}))
}
fn current_utc_micros() -> i64 {
pub(crate) fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
@@ -0,0 +1,91 @@
use axum::{
extract::{Request, State},
http::{HeaderMap, StatusCode, header::AUTHORIZATION},
middleware::Next,
response::Response,
};
use spacetime_client::ExternalApiKeyAuthenticateRecordInput;
use tracing::warn;
use crate::{
editor_project::current_utc_micros,
external_api_keys::{hash_external_api_key, map_external_api_key_error},
http_error::AppError,
request_context::RequestContext,
state::AppState,
};
#[derive(Clone, Debug)]
pub struct ExternalApiPrincipal {
owner_user_id: String,
key_id: String,
scopes: Vec<String>,
}
impl ExternalApiPrincipal {
pub fn owner_user_id(&self) -> &str {
self.owner_user_id.as_str()
}
pub fn key_id(&self) -> &str {
self.key_id.as_str()
}
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.iter().any(|item| item == scope)
}
}
pub async fn require_external_api_key(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Result<Response, AppError> {
let request_id = request
.extensions()
.get::<RequestContext>()
.map(|context| context.request_id().to_string())
.unwrap_or_else(|| "unknown".to_string());
let raw_key = extract_external_api_bearer(request.headers())?;
let key = state
.spacetime_client()
.authenticate_external_api_key(ExternalApiKeyAuthenticateRecordInput {
key_hash: hash_external_api_key(raw_key.as_str()),
used_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
warn!(
%request_id,
error = %error,
"外部 API Key 校验失败"
);
map_external_api_key_error(error)
})?;
let principal = ExternalApiPrincipal {
owner_user_id: key.owner_user_id,
key_id: key.key_id,
scopes: key.scopes,
};
request.extensions_mut().insert(principal.clone());
let mut response = next.run(request).await;
response.extensions_mut().insert(principal);
Ok(response)
}
fn extract_external_api_bearer(headers: &HeaderMap) -> Result<String, AppError> {
let authorization = headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))?;
authorization
.strip_prefix("Bearer ")
.or_else(|| authorization.strip_prefix("bearer "))
.map(str::trim)
.filter(|token| !token.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))
}
@@ -0,0 +1,243 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
};
use platform_auth::hash_refresh_session_token;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_kernel::{build_prefixed_uuid_id, new_uuid_simple_string};
use spacetime_client::{
ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord, ExternalApiKeyRevokeRecordInput,
SpacetimeClientError,
};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken,
editor_project::current_utc_micros, http_error::AppError, request_context::RequestContext,
state::AppState,
};
const EXTERNAL_API_KEY_ID_PREFIX: &str = "external-api-key-";
const EXTERNAL_API_KEY_SECRET_PREFIX: &str = "tnr_sk_";
const EXTERNAL_API_KEY_PREFIX_VISIBLE_CHARS: usize = 18;
const EXTERNAL_API_KEY_SCOPES: [&str; 3] =
["editor:project", "editor:canvas", "editor:image-generate"];
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalApiKeyCreateRequest {
name: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalApiKeyPayload {
key_id: String,
name: String,
key_prefix: String,
scopes: Vec<String>,
created_at: String,
last_used_at: Option<String>,
revoked_at: Option<String>,
updated_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalApiKeyCreateResponse {
api_key: String,
key: ExternalApiKeyPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalApiKeyListResponse {
keys: Vec<ExternalApiKeyPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalApiKeyResponse {
key: ExternalApiKeyPayload,
}
pub async fn list_external_api_keys(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, AppError> {
let keys = state
.spacetime_client()
.list_external_api_keys(authenticated.claims().user_id().to_string())
.await
.map_err(map_external_api_key_error)?
.into_iter()
.map(external_api_key_payload_from_record)
.collect();
Ok(json_success_body(
Some(&request_context),
ExternalApiKeyListResponse { keys },
))
}
pub async fn create_external_api_key(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ExternalApiKeyCreateRequest>,
) -> Result<Json<Value>, AppError> {
let raw_key = generate_external_api_key_secret();
let key_prefix = external_api_key_prefix(raw_key.as_str());
let key = state
.spacetime_client()
.create_external_api_key(ExternalApiKeyCreateRecordInput {
key_id: build_prefixed_uuid_id(EXTERNAL_API_KEY_ID_PREFIX),
owner_user_id: authenticated.claims().user_id().to_string(),
name: payload
.name
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "外部 API Key".to_string()),
key_prefix,
key_hash: hash_external_api_key(raw_key.as_str()),
scopes: default_external_api_key_scopes(),
now_micros: current_utc_micros(),
})
.await
.map_err(map_external_api_key_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalApiKeyCreateResponse {
api_key: raw_key,
key: external_api_key_payload_from_record(key),
},
))
}
pub async fn revoke_external_api_key(
State(state): State<AppState>,
Path(key_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, AppError> {
let key = state
.spacetime_client()
.revoke_external_api_key(ExternalApiKeyRevokeRecordInput {
key_id,
owner_user_id: authenticated.claims().user_id().to_string(),
revoked_at_micros: current_utc_micros(),
})
.await
.map_err(map_external_api_key_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalApiKeyResponse {
key: external_api_key_payload_from_record(key),
},
))
}
pub(crate) fn hash_external_api_key(raw_key: &str) -> String {
hash_refresh_session_token(raw_key)
}
pub(crate) fn default_external_api_key_scopes() -> Vec<String> {
EXTERNAL_API_KEY_SCOPES
.iter()
.map(|scope| (*scope).to_string())
.collect()
}
fn generate_external_api_key_secret() -> String {
format!(
"{EXTERNAL_API_KEY_SECRET_PREFIX}{}.{}",
new_uuid_simple_string(),
new_uuid_simple_string()
)
}
fn external_api_key_prefix(raw_key: &str) -> String {
raw_key
.chars()
.take(EXTERNAL_API_KEY_PREFIX_VISIBLE_CHARS)
.collect()
}
fn external_api_key_payload_from_record(record: ExternalApiKeyRecord) -> ExternalApiKeyPayload {
ExternalApiKeyPayload {
key_id: record.key_id,
name: record.name,
key_prefix: record.key_prefix,
scopes: record.scopes,
created_at: record.created_at,
last_used_at: record.last_used_at,
revoked_at: record.revoked_at,
updated_at: record.updated_at,
}
}
pub(crate) fn map_external_api_key_error(error: SpacetimeClientError) -> AppError {
match error {
SpacetimeClientError::Procedure(message)
if message.contains("不存在") || message.contains("已失效") =>
{
AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({
"provider": "external-api-key",
"message": message,
}))
}
SpacetimeClientError::Procedure(message) if message.contains("无权") => {
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": "external-api-key",
"message": message,
}))
}
SpacetimeClientError::Runtime(message) | SpacetimeClientError::Procedure(message) => {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "external-api-key",
"message": message,
}))
}
other => AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": other.to_string(),
})),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_external_api_key_uses_public_prefix_only_for_display() {
let raw_key = generate_external_api_key_secret();
let key_prefix = external_api_key_prefix(raw_key.as_str());
assert!(raw_key.starts_with(EXTERNAL_API_KEY_SECRET_PREFIX));
assert_eq!(
key_prefix.chars().count(),
EXTERNAL_API_KEY_PREFIX_VISIBLE_CHARS
);
assert!(raw_key.starts_with(key_prefix.as_str()));
assert_ne!(hash_external_api_key(raw_key.as_str()), raw_key);
}
#[test]
fn default_external_api_key_scopes_cover_editor_openapi_v1() {
let scopes = default_external_api_key_scopes();
assert_eq!(
scopes,
vec![
"editor:project".to_string(),
"editor:canvas".to_string(),
"editor:image-generate".to_string(),
]
);
}
}
@@ -0,0 +1,439 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::{StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
EditorAssetCreateRecordInput, EditorProjectCreateRecordInput, EditorProjectGetRecordInput,
EditorProjectLayoutSaveRecordInput, EditorProjectResourceCreateRecordInput,
};
use crate::{
api_response::json_success_body,
editor_project::{
EDITOR_ASSET_ID_PREFIX, EDITOR_PROJECT_DEFAULT_TITLE, EDITOR_PROJECT_ID_PREFIX,
EDITOR_RESOURCE_ID_PREFIX, EditorAssetPayload, EditorCanvasViewportPayload,
EditorProjectPayload, EditorProjectResourcePayload, current_utc_micros,
data_url_from_image_bytes, editor_asset_payload_from_record,
editor_project_payload_from_record, editor_project_resource_payload_from_record,
map_editor_project_error, normalize_optional_string, persist_editor_generated_image,
serialize_editor_layers,
},
external_api_auth::ExternalApiPrincipal,
http_error::AppError,
openai_image_generation::{
GPT_IMAGE_2_MODEL, build_openai_image_http_client,
create_openai_image_edit_with_references_and_model,
create_openai_image_generation_with_model, require_openai_image_settings,
},
request_context::RequestContext,
state::AppState,
};
const EXTERNAL_EDITOR_PROVIDER: &str = "external-editor-api";
const EXTERNAL_EDITOR_IMAGE_ASSET_KIND: &str = "editor_external_image";
const EXTERNAL_EDITOR_IMAGE_SLOT: &str = "external-image";
const EXTERNAL_EDITOR_IMAGE_SOURCE_TYPE: &str = "generated";
const EXTERNAL_EDITOR_IMAGE_PROVIDER: &str = "VectorEngine";
const EXTERNAL_EDITOR_IMAGE_DEFAULT_SIZE: &str = "1024x1024";
const SCOPE_EDITOR_PROJECT: &str = "editor:project";
const SCOPE_EDITOR_CANVAS: &str = "editor:canvas";
const SCOPE_EDITOR_IMAGE_GENERATE: &str = "editor:image-generate";
const OPENAPI_JSON: &str =
include_str!("../../../../docs/openapi/genarrative-external-v1.openapi.json");
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectCreateRequest {
title: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorCanvasSaveRequest {
viewport: EditorCanvasViewportPayload,
layers: Value,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorImageGenerationRequest {
prompt: String,
project_id: Option<String>,
title: Option<String>,
size: Option<String>,
reference_image_srcs: Option<Vec<String>>,
layers: Option<Value>,
viewport: Option<EditorCanvasViewportPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectResponse {
project: EditorProjectPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorImageGenerationResponse {
image_src: String,
width: u32,
height: u32,
prompt: String,
actual_prompt: Option<String>,
model: String,
provider: &'static str,
task_id: String,
asset: EditorAssetPayload,
resource: Option<EditorProjectResourcePayload>,
project: Option<EditorProjectPayload>,
}
pub async fn openapi_json() -> Response {
(
[(CONTENT_TYPE, "application/json; charset=utf-8")],
OPENAPI_JSON,
)
.into_response()
}
pub async fn create_external_editor_project(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<ExternalEditorProjectCreateRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_PROJECT)?;
let project = state
.spacetime_client()
.create_editor_project(EditorProjectCreateRecordInput {
project_id: build_prefixed_uuid_id(EDITOR_PROJECT_ID_PREFIX),
owner_user_id: principal.owner_user_id().to_string(),
title: normalize_project_title(payload.title),
now_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorProjectResponse {
project: editor_project_payload_from_record(project),
},
))
}
pub async fn get_external_editor_project(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_PROJECT)?;
let project = state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id,
owner_user_id: principal.owner_user_id().to_string(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorProjectResponse {
project: editor_project_payload_from_record(project),
},
))
}
pub async fn save_external_editor_canvas(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<ExternalEditorCanvasSaveRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_CANVAS)?;
let project = state
.spacetime_client()
.save_editor_project_layout(EditorProjectLayoutSaveRecordInput {
project_id,
owner_user_id: principal.owner_user_id().to_string(),
viewport: payload.viewport.into_record(),
layers_json: serialize_editor_layers(payload.layers)?,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorProjectResponse {
project: editor_project_payload_from_record(project),
},
))
}
pub async fn generate_external_editor_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<ExternalEditorImageGenerationRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
let prompt = payload.prompt.trim().to_string();
if prompt.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": EXTERNAL_EDITOR_PROVIDER,
"message": "生成提示词不能为空",
})),
);
}
let project_id = normalize_optional_string(payload.project_id);
let asset_title = normalize_asset_title(payload.title.as_deref(), prompt.as_str());
let size = normalize_external_image_size(payload.size.as_deref());
let reference_images = payload
.reference_image_srcs
.unwrap_or_default()
.into_iter()
.filter_map(|source| {
let trimmed = source.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
})
.take(5)
.map(|source| crate::editor_project::parse_editor_reference_image(source.as_str()))
.collect::<Result<Vec<_>, _>>()?;
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
&request_context,
Some(principal.owner_user_id().to_string()),
project_id.clone(),
);
let http_client = build_openai_image_http_client(&settings)?;
let generated = if reference_images.is_empty() {
create_openai_image_generation_with_model(
&http_client,
&settings,
GPT_IMAGE_2_MODEL,
prompt.as_str(),
None,
size.as_str(),
1,
&[],
"外部 OpenAPI 编辑器美术生图",
)
.await?
} else {
create_openai_image_edit_with_references_and_model(
&http_client,
&settings,
GPT_IMAGE_2_MODEL,
prompt.as_str(),
None,
size.as_str(),
1,
reference_images.as_slice(),
"外部 OpenAPI 编辑器参考图美术生图",
)
.await?
};
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "VectorEngine 未返回图片",
}))
})?;
let (width, height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.unwrap_or((1024, 1024));
let image_src = data_url_from_image_bytes(image.mime_type.as_str(), image.bytes.as_slice());
let persisted = persist_editor_generated_image(
&state,
principal.owner_user_id(),
generated.task_id.as_str(),
&image,
prompt.as_str(),
generated.actual_prompt.as_deref(),
EXTERNAL_EDITOR_IMAGE_ASSET_KIND,
"external-images",
EXTERNAL_EDITOR_IMAGE_SLOT,
)
.await?;
let now_micros = current_utc_micros();
let asset = state
.spacetime_client()
.create_editor_asset(EditorAssetCreateRecordInput {
asset_id: build_prefixed_uuid_id(EDITOR_ASSET_ID_PREFIX),
owner_user_id: principal.owner_user_id().to_string(),
folder_id: default_asset_folder_id(principal.owner_user_id()),
label: asset_title,
asset_object_id: Some(persisted.asset_object_id.clone()),
image_src: image_src.clone(),
object_key: Some(persisted.object_key.clone()),
width,
height,
source_type: EXTERNAL_EDITOR_IMAGE_SOURCE_TYPE.to_string(),
prompt: Some(prompt.clone()),
actual_prompt: generated.actual_prompt.clone(),
model: Some(GPT_IMAGE_2_MODEL.to_string()),
provider: Some(EXTERNAL_EDITOR_IMAGE_PROVIDER.to_string()),
task_id: Some(generated.task_id.clone()),
now_micros,
})
.await
.map_err(map_editor_project_error)?;
let resource = if let Some(project_id) = project_id.clone() {
Some(
state
.spacetime_client()
.create_editor_project_resource(EditorProjectResourceCreateRecordInput {
resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX),
project_id,
owner_user_id: principal.owner_user_id().to_string(),
asset_object_id: Some(persisted.asset_object_id.clone()),
image_src: image_src.clone(),
object_key: Some(persisted.object_key.clone()),
width,
height,
source_type: EXTERNAL_EDITOR_IMAGE_SOURCE_TYPE.to_string(),
prompt: Some(prompt.clone()),
actual_prompt: generated.actual_prompt.clone(),
model: Some(GPT_IMAGE_2_MODEL.to_string()),
provider: Some(EXTERNAL_EDITOR_IMAGE_PROVIDER.to_string()),
task_id: Some(generated.task_id.clone()),
source_resource_id: None,
updated_at_micros: now_micros,
})
.await
.map_err(map_editor_project_error)?,
)
} else {
None
};
let project = if let (Some(project_id), Some(layers), Some(viewport)) =
(project_id, payload.layers, payload.viewport)
{
Some(
state
.spacetime_client()
.save_editor_project_layout(EditorProjectLayoutSaveRecordInput {
project_id,
owner_user_id: principal.owner_user_id().to_string(),
viewport: viewport.into_record(),
layers_json: serialize_editor_layers(layers)?,
updated_at_micros: now_micros,
})
.await
.map_err(map_editor_project_error)?,
)
} else {
None
};
Ok(json_success_body(
Some(&request_context),
ExternalEditorImageGenerationResponse {
image_src,
width,
height,
prompt,
actual_prompt: generated.actual_prompt,
model: GPT_IMAGE_2_MODEL.to_string(),
provider: EXTERNAL_EDITOR_IMAGE_PROVIDER,
task_id: generated.task_id,
asset: editor_asset_payload_from_record(asset),
resource: resource.map(editor_project_resource_payload_from_record),
project: project.map(editor_project_payload_from_record),
},
))
}
fn require_scope(principal: &ExternalApiPrincipal, scope: &str) -> Result<(), AppError> {
if principal.has_scope(scope) {
return Ok(());
}
Err(
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": EXTERNAL_EDITOR_PROVIDER,
"keyId": principal.key_id(),
"scope": scope,
"message": "API Key 缺少所需权限",
})),
)
}
fn normalize_project_title(title: Option<String>) -> String {
title
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| EDITOR_PROJECT_DEFAULT_TITLE.to_string())
}
fn normalize_asset_title(title: Option<&str>, prompt: &str) -> String {
title
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| prompt.trim().split('\n').next())
.map(|value| value.chars().take(40).collect())
.unwrap_or_else(|| "外部生成图片".to_string())
}
fn normalize_external_image_size(size: Option<&str>) -> String {
match size.map(str::trim).filter(|value| !value.is_empty()) {
Some(value @ ("1024x1024" | "1536x1024" | "1024x1536" | "2048x1152" | "2048x2048")) => {
value.to_string()
}
_ => EXTERNAL_EDITOR_IMAGE_DEFAULT_SIZE.to_string(),
}
}
fn default_asset_folder_id(owner_user_id: &str) -> String {
format!("{owner_user_id}:asset-folder:project")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exported_openapi_json_contains_external_editor_routes_and_security() {
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
assert_eq!(parsed["openapi"], "3.1.0");
assert!(
parsed["paths"]
.get("/api/external/v1/editor/projects")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/images/generations")
.is_some()
);
assert_eq!(
parsed["components"]["securitySchemes"]["ExternalApiKey"]["scheme"],
"bearer"
);
}
#[test]
fn external_image_size_falls_back_to_default_for_unknown_values() {
assert_eq!(
normalize_external_image_size(Some("1536x1024")),
"1536x1024"
);
assert_eq!(
normalize_external_image_size(Some("4096x4096")),
EXTERNAL_EDITOR_IMAGE_DEFAULT_SIZE
);
assert_eq!(
normalize_external_image_size(None),
EXTERNAL_EDITOR_IMAGE_DEFAULT_SIZE
);
}
}
+3
View File
@@ -42,6 +42,9 @@ mod edutainment_baby_drawing;
mod edutainment_baby_object;
mod error_middleware;
mod external_api_audit;
mod external_api_auth;
mod external_api_keys;
mod external_editor_api;
mod external_generation;
mod external_generation_worker;
mod external_generation_worker_controller;
@@ -6,6 +6,7 @@ pub mod big_fish;
pub mod custom_world;
pub mod editor_project;
pub mod edutainment;
pub mod external_api;
pub mod external_generation;
pub mod health;
pub mod internal;
@@ -0,0 +1,54 @@
use axum::{
Router,
extract::DefaultBodyLimit,
middleware,
routing::{get, patch, post},
};
use crate::{
external_api_auth::require_external_api_key,
external_editor_api::{
create_external_editor_project, generate_external_editor_image,
get_external_editor_project, openapi_json, save_external_editor_canvas,
},
state::AppState,
};
const EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route("/api/external/v1/openapi.json", get(openapi_json))
.route(
"/api/external/v1/editor/projects",
post(create_external_editor_project).route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/projects/{project_id}",
get(get_external_editor_project).route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/projects/{project_id}/canvas",
patch(save_external_editor_canvas).route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/images/generations",
post(generate_external_editor_image)
.layer(DefaultBodyLimit::max(
EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state,
require_external_api_key,
)),
)
}
@@ -1,10 +1,11 @@
use axum::{
Router, middleware,
routing::{get, patch, post},
routing::{delete, get, patch, post},
};
use crate::{
auth::require_bearer_auth,
external_api_keys::{create_external_api_key, list_external_api_keys, revoke_external_api_key},
profile_identity::update_profile_identity,
runtime_profile::{
claim_profile_task_reward, confirm_wechat_profile_recharge_order,
@@ -32,6 +33,22 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.route(
"/api/profile/api-keys",
get(list_external_api_keys)
.post(create_external_api_key)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/api-keys/{key_id}",
delete(revoke_external_api_key).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/wallet-ledger",
get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state(
@@ -0,0 +1,94 @@
use super::*;
impl SpacetimeClient {
pub async fn create_external_api_key(
&self,
input: ExternalApiKeyCreateRecordInput,
) -> Result<ExternalApiKeyRecord, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"create_external_api_key_and_return",
move |connection, sender| {
connection
.procedures()
.create_external_api_key_and_return_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_external_api_key_single_procedure_result);
send_once(&sender, mapped);
});
},
)
.await
}
pub async fn list_external_api_keys(
&self,
owner_user_id: String,
) -> Result<Vec<ExternalApiKeyRecord>, SpacetimeClientError> {
let procedure_input = ExternalApiKeyListInput { owner_user_id };
self.call_after_connect(
"list_external_api_keys_and_return",
move |connection, sender| {
connection
.procedures()
.list_external_api_keys_and_return_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_external_api_key_list_procedure_result);
send_once(&sender, mapped);
});
},
)
.await
}
pub async fn revoke_external_api_key(
&self,
input: ExternalApiKeyRevokeRecordInput,
) -> Result<ExternalApiKeyRecord, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"revoke_external_api_key_and_return",
move |connection, sender| {
connection
.procedures()
.revoke_external_api_key_and_return_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_external_api_key_single_procedure_result);
send_once(&sender, mapped);
});
},
)
.await
}
pub async fn authenticate_external_api_key(
&self,
input: ExternalApiKeyAuthenticateRecordInput,
) -> Result<ExternalApiKeyRecord, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"authenticate_external_api_key_and_return",
move |connection, sender| {
connection
.procedures()
.authenticate_external_api_key_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_external_api_key_single_procedure_result);
send_once(&sender, mapped);
},
);
},
)
.await
}
}
+13 -10
View File
@@ -37,16 +37,18 @@ pub use mapper::{
EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput,
EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, EditorProjectRecord,
EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput,
EditorProjectResourceRecord, ExternalGenerationJobClaimRecordInput,
ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput,
ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput,
ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput,
ExternalGenerationQueueStatsRecord, JumpHopActionRequest, JumpHopActionResponse,
JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse,
JumpHopGalleryCardResponse, JumpHopGalleryDetailResponse, JumpHopGalleryResponse,
JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult,
JumpHopLastJump, JumpHopPath, JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse,
JumpHopRunStatus, JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse,
EditorProjectResourceRecord, ExternalApiKeyAuthenticateRecordInput,
ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord, ExternalApiKeyRevokeRecordInput,
ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput,
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput,
ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord,
ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationQueueStatsRecord,
JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset,
JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse,
JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus,
JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath,
JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus,
JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse,
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset,
JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse,
@@ -125,6 +127,7 @@ pub mod big_fish;
pub mod combat;
pub mod custom_world;
pub mod editor_project;
pub mod external_api_key;
pub mod external_generation;
pub mod inventory;
@@ -9,6 +9,7 @@ mod combat;
mod common;
mod custom_world;
mod editor_project;
mod external_api_key;
mod external_generation;
mod inventory;
@@ -80,6 +81,10 @@ pub use self::editor_project::{
EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord,
};
pub use self::external_api_key::{
ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord,
ExternalApiKeyRevokeRecordInput,
};
pub use self::external_generation::{
ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput,
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput,
@@ -203,6 +208,9 @@ pub(crate) use self::editor_project::{
map_editor_project_optional_procedure_result, map_editor_project_required_procedure_result,
map_editor_project_resource_procedure_result,
};
pub(crate) use self::external_api_key::{
map_external_api_key_list_procedure_result, map_external_api_key_single_procedure_result,
};
pub(crate) use self::external_generation::{
map_external_generation_job_claim_result, map_external_generation_job_procedure_result,
map_external_generation_queue_stats_result,
@@ -0,0 +1,118 @@
use super::*;
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ExternalApiKeyRecord {
pub key_id: String,
pub owner_user_id: String,
pub name: String,
pub key_prefix: String,
pub scopes: Vec<String>,
pub created_at: String,
pub last_used_at: Option<String>,
pub revoked_at: Option<String>,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExternalApiKeyCreateRecordInput {
pub key_id: String,
pub owner_user_id: String,
pub name: String,
pub key_prefix: String,
pub key_hash: String,
pub scopes: Vec<String>,
pub now_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExternalApiKeyRevokeRecordInput {
pub key_id: String,
pub owner_user_id: String,
pub revoked_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExternalApiKeyAuthenticateRecordInput {
pub key_hash: String,
pub used_at_micros: i64,
}
impl From<ExternalApiKeyCreateRecordInput> for crate::module_bindings::ExternalApiKeyCreateInput {
fn from(input: ExternalApiKeyCreateRecordInput) -> Self {
Self {
key_id: input.key_id,
owner_user_id: input.owner_user_id,
name: input.name,
key_prefix: input.key_prefix,
key_hash: input.key_hash,
scopes_json: serde_json::to_string(&input.scopes).unwrap_or_else(|_| "[]".to_string()),
now_micros: input.now_micros,
}
}
}
impl From<ExternalApiKeyRevokeRecordInput> for crate::module_bindings::ExternalApiKeyRevokeInput {
fn from(input: ExternalApiKeyRevokeRecordInput) -> Self {
Self {
key_id: input.key_id,
owner_user_id: input.owner_user_id,
revoked_at_micros: input.revoked_at_micros,
}
}
}
impl From<ExternalApiKeyAuthenticateRecordInput>
for crate::module_bindings::ExternalApiKeyAuthenticateInput
{
fn from(input: ExternalApiKeyAuthenticateRecordInput) -> Self {
Self {
key_hash: input.key_hash,
used_at_micros: input.used_at_micros,
}
}
}
pub(crate) fn map_external_api_key_single_procedure_result(
result: ExternalApiKeyProcedureResult,
) -> Result<ExternalApiKeyRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
result
.key
.map(map_external_api_key_snapshot)
.transpose()?
.ok_or_else(|| SpacetimeClientError::missing_snapshot("外部 API Key 快照"))
}
pub(crate) fn map_external_api_key_list_procedure_result(
result: ExternalApiKeyProcedureResult,
) -> Result<Vec<ExternalApiKeyRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
result
.keys
.into_iter()
.map(map_external_api_key_snapshot)
.collect()
}
fn map_external_api_key_snapshot(
snapshot: ExternalApiKeySnapshot,
) -> Result<ExternalApiKeyRecord, SpacetimeClientError> {
Ok(ExternalApiKeyRecord {
key_id: snapshot.key_id,
owner_user_id: snapshot.owner_user_id,
name: snapshot.name,
key_prefix: snapshot.key_prefix,
scopes: serde_json::from_str::<Vec<String>>(snapshot.scopes_json.as_str())
.map_err(SpacetimeClientError::validation_failed)?,
created_at: format_timestamp_micros(snapshot.created_at_micros),
last_used_at: snapshot.last_used_at_micros.map(format_timestamp_micros),
revoked_at: snapshot.revoked_at_micros.map(format_timestamp_micros),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
})
}
@@ -101,6 +101,7 @@ pub mod auth_store_snapshot_record_type;
pub mod auth_store_snapshot_table;
pub mod auth_store_snapshot_type;
pub mod auth_store_snapshot_upsert_input_type;
pub mod authenticate_external_api_key_and_return_procedure;
pub mod authorize_database_migration_operator_procedure;
pub mod bark_battle_draft_config_row_type;
pub mod bark_battle_draft_config_snapshot_type;
@@ -240,6 +241,7 @@ pub mod create_editor_asset_and_return_procedure;
pub mod create_editor_asset_folder_and_return_procedure;
pub mod create_editor_project_and_return_procedure;
pub mod create_editor_project_resource_and_return_procedure;
pub mod create_external_api_key_and_return_procedure;
pub mod create_jump_hop_agent_session_procedure;
pub mod create_match_3_d_agent_session_procedure;
pub mod create_profile_recharge_order_and_return_procedure;
@@ -397,6 +399,14 @@ pub mod equip_inventory_item_input_type;
pub mod execute_custom_world_agent_action_procedure;
pub mod export_auth_store_snapshot_from_tables_procedure;
pub mod export_database_migration_to_file_procedure;
pub mod external_api_key_authenticate_input_type;
pub mod external_api_key_create_input_type;
pub mod external_api_key_list_input_type;
pub mod external_api_key_procedure_result_type;
pub mod external_api_key_revoke_input_type;
pub mod external_api_key_snapshot_type;
pub mod external_api_key_table;
pub mod external_api_key_type;
pub mod external_generation_job_claim_input_type;
pub mod external_generation_job_complete_input_type;
pub mod external_generation_job_enqueue_input_type;
@@ -550,6 +560,7 @@ pub mod list_custom_world_gallery_entries_procedure;
pub mod list_custom_world_profiles_procedure;
pub mod list_custom_world_works_procedure;
pub mod list_editor_projects_and_return_procedure;
pub mod list_external_api_keys_and_return_procedure;
pub mod list_jump_hop_works_procedure;
pub mod list_match_3_d_works_procedure;
pub mod list_platform_browse_history_procedure;
@@ -869,6 +880,7 @@ pub mod restart_square_hole_run_procedure;
pub mod resume_profile_save_archive_and_return_procedure;
pub mod retry_puzzle_clear_level_run_procedure;
pub mod revoke_database_migration_operator_procedure;
pub mod revoke_external_api_key_and_return_procedure;
pub mod rpg_agent_draft_card_kind_type;
pub mod rpg_agent_draft_card_status_type;
pub mod rpg_agent_message_kind_type;
@@ -1291,6 +1303,7 @@ pub use auth_store_snapshot_record_type::AuthStoreSnapshotRecord;
pub use auth_store_snapshot_table::*;
pub use auth_store_snapshot_type::AuthStoreSnapshot;
pub use auth_store_snapshot_upsert_input_type::AuthStoreSnapshotUpsertInput;
pub use authenticate_external_api_key_and_return_procedure::authenticate_external_api_key_and_return;
pub use authorize_database_migration_operator_procedure::authorize_database_migration_operator;
pub use bark_battle_draft_config_row_type::BarkBattleDraftConfigRow;
pub use bark_battle_draft_config_snapshot_type::BarkBattleDraftConfigSnapshot;
@@ -1430,6 +1443,7 @@ pub use create_editor_asset_and_return_procedure::create_editor_asset_and_return
pub use create_editor_asset_folder_and_return_procedure::create_editor_asset_folder_and_return;
pub use create_editor_project_and_return_procedure::create_editor_project_and_return;
pub use create_editor_project_resource_and_return_procedure::create_editor_project_resource_and_return;
pub use create_external_api_key_and_return_procedure::create_external_api_key_and_return;
pub use create_jump_hop_agent_session_procedure::create_jump_hop_agent_session;
pub use create_match_3_d_agent_session_procedure::create_match_3_d_agent_session;
pub use create_profile_recharge_order_and_return_procedure::create_profile_recharge_order_and_return;
@@ -1587,6 +1601,14 @@ pub use equip_inventory_item_input_type::EquipInventoryItemInput;
pub use execute_custom_world_agent_action_procedure::execute_custom_world_agent_action;
pub use export_auth_store_snapshot_from_tables_procedure::export_auth_store_snapshot_from_tables;
pub use export_database_migration_to_file_procedure::export_database_migration_to_file;
pub use external_api_key_authenticate_input_type::ExternalApiKeyAuthenticateInput;
pub use external_api_key_create_input_type::ExternalApiKeyCreateInput;
pub use external_api_key_list_input_type::ExternalApiKeyListInput;
pub use external_api_key_procedure_result_type::ExternalApiKeyProcedureResult;
pub use external_api_key_revoke_input_type::ExternalApiKeyRevokeInput;
pub use external_api_key_snapshot_type::ExternalApiKeySnapshot;
pub use external_api_key_table::*;
pub use external_api_key_type::ExternalApiKey;
pub use external_generation_job_claim_input_type::ExternalGenerationJobClaimInput;
pub use external_generation_job_complete_input_type::ExternalGenerationJobCompleteInput;
pub use external_generation_job_enqueue_input_type::ExternalGenerationJobEnqueueInput;
@@ -1740,6 +1762,7 @@ pub use list_custom_world_gallery_entries_procedure::list_custom_world_gallery_e
pub use list_custom_world_profiles_procedure::list_custom_world_profiles;
pub use list_custom_world_works_procedure::list_custom_world_works;
pub use list_editor_projects_and_return_procedure::list_editor_projects_and_return;
pub use list_external_api_keys_and_return_procedure::list_external_api_keys_and_return;
pub use list_jump_hop_works_procedure::list_jump_hop_works;
pub use list_match_3_d_works_procedure::list_match_3_d_works;
pub use list_platform_browse_history_procedure::list_platform_browse_history;
@@ -2059,6 +2082,7 @@ pub use restart_square_hole_run_procedure::restart_square_hole_run;
pub use resume_profile_save_archive_and_return_procedure::resume_profile_save_archive_and_return;
pub use retry_puzzle_clear_level_run_procedure::retry_puzzle_clear_level_run;
pub use revoke_database_migration_operator_procedure::revoke_database_migration_operator;
pub use revoke_external_api_key_and_return_procedure::revoke_external_api_key_and_return;
pub use rpg_agent_draft_card_kind_type::RpgAgentDraftCardKind;
pub use rpg_agent_draft_card_status_type::RpgAgentDraftCardStatus;
pub use rpg_agent_message_kind_type::RpgAgentMessageKind;
@@ -2702,6 +2726,7 @@ pub struct DbUpdate {
editor_canvas: __sdk::TableUpdate<EditorCanvas>,
editor_project: __sdk::TableUpdate<EditorProject>,
editor_project_resource: __sdk::TableUpdate<EditorProjectResource>,
external_api_key: __sdk::TableUpdate<ExternalApiKey>,
external_generation_job: __sdk::TableUpdate<ExternalGenerationJob>,
inventory_slot: __sdk::TableUpdate<InventorySlot>,
jump_hop_agent_session: __sdk::TableUpdate<JumpHopAgentSessionRow>,
@@ -2930,6 +2955,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
"editor_project_resource" => db_update.editor_project_resource.append(
editor_project_resource_table::parse_table_update(table_update)?,
),
"external_api_key" => db_update
.external_api_key
.append(external_api_key_table::parse_table_update(table_update)?),
"external_generation_job" => db_update.external_generation_job.append(
external_generation_job_table::parse_table_update(table_update)?,
),
@@ -3414,6 +3442,9 @@ impl __sdk::DbUpdate for DbUpdate {
&self.editor_project_resource,
)
.with_updates_by_pk(|row| &row.resource_id);
diff.external_api_key = cache
.apply_diff_to_table::<ExternalApiKey>("external_api_key", &self.external_api_key)
.with_updates_by_pk(|row| &row.key_id);
diff.external_generation_job = cache
.apply_diff_to_table::<ExternalGenerationJob>(
"external_generation_job",
@@ -3962,6 +3993,9 @@ impl __sdk::DbUpdate for DbUpdate {
"editor_project_resource" => db_update
.editor_project_resource
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"external_api_key" => db_update
.external_api_key
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"external_generation_job" => db_update
.external_generation_job
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
@@ -4347,6 +4381,9 @@ impl __sdk::DbUpdate for DbUpdate {
"editor_project_resource" => db_update
.editor_project_resource
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"external_api_key" => db_update
.external_api_key
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"external_generation_job" => db_update
.external_generation_job
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
@@ -4648,6 +4685,7 @@ pub struct AppliedDiff<'r> {
editor_canvas: __sdk::TableAppliedDiff<'r, EditorCanvas>,
editor_project: __sdk::TableAppliedDiff<'r, EditorProject>,
editor_project_resource: __sdk::TableAppliedDiff<'r, EditorProjectResource>,
external_api_key: __sdk::TableAppliedDiff<'r, ExternalApiKey>,
external_generation_job: __sdk::TableAppliedDiff<'r, ExternalGenerationJob>,
inventory_slot: __sdk::TableAppliedDiff<'r, InventorySlot>,
jump_hop_agent_session: __sdk::TableAppliedDiff<'r, JumpHopAgentSessionRow>,
@@ -4954,6 +4992,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
&self.editor_project_resource,
event,
);
callbacks.invoke_table_row_callbacks::<ExternalApiKey>(
"external_api_key",
&self.external_api_key,
event,
);
callbacks.invoke_table_row_callbacks::<ExternalGenerationJob>(
"external_generation_job",
&self.external_generation_job,
@@ -6046,6 +6089,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
editor_canvas_table::register_table(client_cache);
editor_project_table::register_table(client_cache);
editor_project_resource_table::register_table(client_cache);
external_api_key_table::register_table(client_cache);
external_generation_job_table::register_table(client_cache);
inventory_slot_table::register_table(client_cache);
jump_hop_agent_session_table::register_table(client_cache);
@@ -6172,6 +6216,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
"editor_canvas",
"editor_project",
"editor_project_resource",
"external_api_key",
"external_generation_job",
"inventory_slot",
"jump_hop_agent_session",
@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::external_api_key_authenticate_input_type::ExternalApiKeyAuthenticateInput;
use super::external_api_key_procedure_result_type::ExternalApiKeyProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AuthenticateExternalApiKeyAndReturnArgs {
pub input: ExternalApiKeyAuthenticateInput,
}
impl __sdk::InModule for AuthenticateExternalApiKeyAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `authenticate_external_api_key_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait authenticate_external_api_key_and_return {
fn authenticate_external_api_key_and_return(&self, input: ExternalApiKeyAuthenticateInput) {
self.authenticate_external_api_key_and_return_then(input, |_, _| {});
}
fn authenticate_external_api_key_and_return_then(
&self,
input: ExternalApiKeyAuthenticateInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<ExternalApiKeyProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl authenticate_external_api_key_and_return for super::RemoteProcedures {
fn authenticate_external_api_key_and_return_then(
&self,
input: ExternalApiKeyAuthenticateInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<ExternalApiKeyProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, ExternalApiKeyProcedureResult>(
"authenticate_external_api_key_and_return",
AuthenticateExternalApiKeyAndReturnArgs { input },
__callback,
);
}
}
@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::external_api_key_create_input_type::ExternalApiKeyCreateInput;
use super::external_api_key_procedure_result_type::ExternalApiKeyProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct CreateExternalApiKeyAndReturnArgs {
pub input: ExternalApiKeyCreateInput,
}
impl __sdk::InModule for CreateExternalApiKeyAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `create_external_api_key_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait create_external_api_key_and_return {
fn create_external_api_key_and_return(&self, input: ExternalApiKeyCreateInput) {
self.create_external_api_key_and_return_then(input, |_, _| {});
}
fn create_external_api_key_and_return_then(
&self,
input: ExternalApiKeyCreateInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<ExternalApiKeyProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl create_external_api_key_and_return for super::RemoteProcedures {
fn create_external_api_key_and_return_then(
&self,
input: ExternalApiKeyCreateInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<ExternalApiKeyProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, ExternalApiKeyProcedureResult>(
"create_external_api_key_and_return",
CreateExternalApiKeyAndReturnArgs { input },
__callback,
);
}
}
@@ -0,0 +1,16 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct ExternalApiKeyAuthenticateInput {
pub key_hash: String,
pub used_at_micros: i64,
}
impl __sdk::InModule for ExternalApiKeyAuthenticateInput {
type Module = super::RemoteModule;
}
@@ -0,0 +1,21 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct ExternalApiKeyCreateInput {
pub key_id: String,
pub owner_user_id: String,
pub name: String,
pub key_prefix: String,
pub key_hash: String,
pub scopes_json: String,
pub now_micros: i64,
}
impl __sdk::InModule for ExternalApiKeyCreateInput {
type Module = super::RemoteModule;
}
@@ -0,0 +1,15 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct ExternalApiKeyListInput {
pub owner_user_id: String,
}
impl __sdk::InModule for ExternalApiKeyListInput {
type Module = super::RemoteModule;
}
@@ -0,0 +1,20 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::external_api_key_snapshot_type::ExternalApiKeySnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct ExternalApiKeyProcedureResult {
pub ok: bool,
pub key: Option<ExternalApiKeySnapshot>,
pub keys: Vec<ExternalApiKeySnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for ExternalApiKeyProcedureResult {
type Module = super::RemoteModule;
}

Some files were not shown because too many files have changed in this diff Show More