Skip to content

Commit 97ca60a

Browse files
vinkiYuBlackHole1
andauthored
feat(provider): add Upstash Redis REST actions (#210)
## Summary / 概要 - Add a locally executable Upstash Redis provider backed by the official REST API. - 新增基于 Upstash 官方 REST API 的本地可执行 Redis Provider。 ## Changes / 改动内容 - Add custom credentials with estUrl and secret estToken, validated through PING. - 新增 estUrl 与敏感 estToken 凭据,并使用 PING 校验连接。 - Add seven string-key actions: get, set, delete, exists, expire, tl, and single-page scan. - 新增 7 个字符串键操作:get、set、delete、exists、expire、 tl 和单页 scan。 - Support atomic SET key value [EX seconds] [NX|XX] writes and preserve Redis TTL semantics. - 支持原子 SET key value [EX seconds] [NX|XX] 写入,并保留 Redis TTL 语义。 - Restrict REST URLs to public https://*.upstash.io root endpoints and route requests through the injected SSRF-protected fetcher. - REST URL 仅允许公网 https://*.upstash.io 根路径端点,所有请求均使用注入的 SSRF 防护 Fetch。 - Handle timeouts, cancellation, JSON response envelopes, upstream errors, and non-JSON error bodies while preserving relevant HTTP status codes. - 处理超时、取消、JSON 响应包络、上游错误和非 JSON 错误响应,并保留关键 HTTP 状态码。 ## Validation / 验证 - scripts/generate-catalog.ts - pm run typecheck - �itest run - 57 files and 548 tests passed - Mock Fetch smoke tests for credential validation, command encoding, output normalization, URL validation, and non-JSON upstream errors ## Notes / 说明 - No npm dependencies or shared runtime changes were added. - 未新增 npm 依赖,也未修改共享运行时。 - package-lock.json is intentionally excluded because it is unrelated local user work. - package-lock.json 为无关的本地用户修改,未包含在本 PR 中。 --------- Co-authored-by: Kevin Cui <bh@bugs.cc>
1 parent 263027e commit 97ca60a

4 files changed

Lines changed: 519 additions & 0 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import type { ActionDefinition } from "../../core/types.ts";
2+
3+
import { s } from "../../core/json-schema.ts";
4+
import { defineProviderAction } from "../../core/provider-definition.ts";
5+
6+
const service = "upstash_redis";
7+
8+
const keySchema = s.nonEmptyString("The Redis key.");
9+
const valueSchema = s.nonEmptyString("The string value stored for the Redis key.");
10+
const expirationSecondsSchema = s.positiveInteger("Expiration time in seconds.");
11+
12+
export const upstashRedisActions: ActionDefinition[] = [
13+
defineProviderAction(service, {
14+
name: "get",
15+
description: "Get the string value stored for one Redis key.",
16+
inputSchema: s.actionInput({ key: keySchema }, ["key"], "The Redis key to retrieve."),
17+
outputSchema: s.actionOutput(
18+
{
19+
value: s.nullableString(
20+
"The stored string value, or null when the key does not exist. Upstash replaces bytes that are not valid UTF-8 with U+FFFD, so only text values round-trip exactly.",
21+
),
22+
},
23+
"The value returned for the Redis key.",
24+
),
25+
followUpActions: ["upstash_redis.set", "upstash_redis.delete", "upstash_redis.expire", "upstash_redis.ttl"],
26+
}),
27+
defineProviderAction(service, {
28+
name: "set",
29+
description: "Store a string value for one Redis key, optionally with an expiration or conditional write.",
30+
inputSchema: s.object(
31+
"The Redis string value to store.",
32+
{
33+
key: keySchema,
34+
value: valueSchema,
35+
expirationSeconds: expirationSecondsSchema,
36+
condition: s.stringEnum(["NX", "XX"], {
37+
description: "Optional Redis write condition: NX stores only a new key; XX stores only an existing key.",
38+
}),
39+
},
40+
{ required: ["key", "value"], optional: ["expirationSeconds", "condition"] },
41+
),
42+
outputSchema: s.actionOutput(
43+
{ stored: s.boolean("Whether Redis stored the value. False means the requested condition was not met.") },
44+
"The result of the Redis SET command.",
45+
),
46+
followUpActions: ["upstash_redis.get", "upstash_redis.ttl"],
47+
}),
48+
defineProviderAction(service, {
49+
name: "delete",
50+
description: "Delete one Redis key.",
51+
inputSchema: s.actionInput({ key: keySchema }, ["key"], "The Redis key to delete."),
52+
outputSchema: s.actionOutput(
53+
{ deleted: s.boolean("Whether Redis deleted an existing key.") },
54+
"The result of the Redis DEL command.",
55+
),
56+
}),
57+
defineProviderAction(service, {
58+
name: "exists",
59+
description: "Check whether one Redis key exists.",
60+
inputSchema: s.actionInput({ key: keySchema }, ["key"], "The Redis key to check."),
61+
outputSchema: s.actionOutput(
62+
{ exists: s.boolean("Whether the Redis key exists.") },
63+
"The result of the Redis EXISTS command.",
64+
),
65+
followUpActions: ["upstash_redis.get", "upstash_redis.ttl"],
66+
}),
67+
defineProviderAction(service, {
68+
name: "expire",
69+
description: "Set or replace the expiration time for one Redis key.",
70+
inputSchema: s.actionInput(
71+
{ key: keySchema, expirationSeconds: expirationSecondsSchema },
72+
["key", "expirationSeconds"],
73+
"The Redis key and its new expiration time.",
74+
),
75+
outputSchema: s.actionOutput(
76+
{ updated: s.boolean("Whether Redis updated the expiration for an existing key.") },
77+
"The result of the Redis EXPIRE command.",
78+
),
79+
followUpActions: ["upstash_redis.ttl"],
80+
}),
81+
defineProviderAction(service, {
82+
name: "ttl",
83+
description: "Get the remaining expiration time for one Redis key.",
84+
inputSchema: s.actionInput({ key: keySchema }, ["key"], "The Redis key whose expiration to retrieve."),
85+
outputSchema: s.actionOutput(
86+
{
87+
ttlSeconds: s.integer(
88+
"Remaining expiration in seconds. -2 means the key does not exist; -1 means the key has no expiration.",
89+
),
90+
},
91+
"The result of the Redis TTL command.",
92+
),
93+
}),
94+
defineProviderAction(service, {
95+
name: "scan",
96+
description: "Scan one page of Redis keys without reading the full keyspace.",
97+
inputSchema: s.object(
98+
"Cursor pagination and optional filters for Redis SCAN.",
99+
{
100+
cursor: s.nonEmptyString("The cursor returned by a previous scan. Omit it to start at cursor 0."),
101+
match: s.nonEmptyString("Optional Redis glob pattern used to filter keys."),
102+
count: s.integer("Optional scan work hint from 1 to 1000.", { minimum: 1, maximum: 1000 }),
103+
},
104+
{ optional: ["cursor", "match", "count"] },
105+
),
106+
outputSchema: s.actionOutput(
107+
{
108+
nextCursor: s.nonEmptyString(
109+
"Cursor to pass to the next scan request. A value of 0 means scanning is complete.",
110+
),
111+
keys: s.stringArray("Keys returned in this scan page."),
112+
complete: s.boolean("Whether this scan reached cursor 0."),
113+
},
114+
"One page returned by Redis SCAN.",
115+
),
116+
}),
117+
];
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { ProviderDefinition } from "../../core/types.ts";
2+
3+
import { upstashRedisActions } from "./actions.ts";
4+
5+
const service = "upstash_redis";
6+
7+
/**
8+
* Upstash Redis provider backed by the official REST API.
9+
*/
10+
export const provider: ProviderDefinition = {
11+
service,
12+
displayName: "Upstash Redis",
13+
description: "Read and manage string keys in an Upstash Redis database through its REST API.",
14+
categories: ["Data", "Developer Tools"],
15+
authTypes: ["custom_credential"],
16+
auth: [
17+
{
18+
type: "custom_credential",
19+
fields: [
20+
{
21+
key: "restUrl",
22+
label: "REST URL",
23+
inputType: "text",
24+
required: true,
25+
secret: false,
26+
placeholder: "https://your-database.upstash.io",
27+
description:
28+
"The HTTPS REST URL shown for an Upstash Redis database in the Upstash console. Only official upstash.io endpoints are supported.",
29+
},
30+
{
31+
key: "restToken",
32+
label: "REST Token",
33+
inputType: "password",
34+
required: true,
35+
secret: true,
36+
placeholder: "Your Upstash REST token",
37+
description:
38+
"The Upstash Redis REST token sent as a Bearer token. A read-only token can run get, exists, and ttl, but Upstash also blocks scan for read-only tokens, so the standard token is required for scan and for every write action.",
39+
},
40+
],
41+
},
42+
],
43+
homepageUrl: "https://upstash.com/redis",
44+
actions: upstashRedisActions,
45+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { CredentialValidators, ExecutionContext, ProviderExecutors } from "../../core/types.ts";
2+
import type { UpstashRedisContext } from "./runtime.ts";
3+
4+
import { defineProviderExecutors, requireCustomCredential } from "../provider-runtime.ts";
5+
import { createUpstashRedisContext, upstashRedisActionHandlers, validateUpstashRedisCredential } from "./runtime.ts";
6+
7+
const service = "upstash_redis";
8+
9+
export const executors: ProviderExecutors = defineProviderExecutors({
10+
service,
11+
handlers: upstashRedisActionHandlers,
12+
async createContext(context: ExecutionContext, fetcher): Promise<UpstashRedisContext> {
13+
const credential = await requireCustomCredential(context, service);
14+
return createUpstashRedisContext(credential.values, fetcher, context.signal);
15+
},
16+
});
17+
18+
export const credentialValidators: CredentialValidators = {
19+
customCredential(input, { fetcher, signal }) {
20+
return validateUpstashRedisCredential(input.values, fetcher, signal);
21+
},
22+
};

0 commit comments

Comments
 (0)