diff --git a/packages/client/lib/commands/COMMAND_DOCS.spec.ts b/packages/client/lib/commands/COMMAND_DOCS.spec.ts new file mode 100644 index 00000000000..c97913214e6 --- /dev/null +++ b/packages/client/lib/commands/COMMAND_DOCS.spec.ts @@ -0,0 +1,154 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import { RESP_TYPES } from '../RESP/decoder'; +import COMMAND_DOCS from './COMMAND_DOCS'; +import { parseArgs } from './generic-transformers'; + +describe('COMMAND DOCS', () => { + testUtils.isVersionGreaterThanHook([7]); + + describe('transformArguments', () => { + it('simple', () => { + assert.deepEqual( + parseArgs(COMMAND_DOCS), + ['COMMAND', 'DOCS'] + ); + }); + + it('string', () => { + assert.deepEqual( + parseArgs(COMMAND_DOCS, 'GET'), + ['COMMAND', 'DOCS', 'GET'] + ); + }); + + it('array', () => { + assert.deepEqual( + parseArgs(COMMAND_DOCS, ['GET', 'SET']), + ['COMMAND', 'DOCS', 'GET', 'SET'] + ); + }); + }); + + describe('transformReply', () => { + it('RESP2 map with nested arguments', () => { + assert.deepEqual( + COMMAND_DOCS.transformReply[2]([ + 'get', + [ + 'summary', 'Get the value of a key', + 'since', '1.0.0', + 'group', 'string', + 'complexity', 'O(1)', + 'history', [['2.2.3', 'Added MSET']], + 'arguments', [ + [ + 'name', 'key', + 'type', 'key', + 'display_text', 'key', + 'key_spec_index', 0 + ] + ] + ], + 'command', + [ + 'summary', 'Get or set information about commands', + 'since', '2.8.13', + 'group', 'server', + 'complexity', 'O(N)', + 'subcommands', [ + 'command|docs', + [ + 'summary', 'Returns documentary information about commands', + 'since', '7.0.0', + 'group', 'server', + 'complexity', 'O(N)' + ] + ] + ] + ] as never), + { + get: { + summary: 'Get the value of a key', + since: '1.0.0', + group: 'string', + complexity: 'O(1)', + history: [['2.2.3', 'Added MSET']], + arguments: [{ + name: 'key', + type: 'key', + display_text: 'key', + key_spec_index: 0 + }] + }, + command: { + summary: 'Get or set information about commands', + since: '2.8.13', + group: 'server', + complexity: 'O(N)', + subcommands: { + 'command|docs': { + summary: 'Returns documentary information about commands', + since: '7.0.0', + group: 'server', + complexity: 'O(N)' + } + } + } + } + ); + }); + }); + + testUtils.testWithClient('client.commandDocs', async client => { + const docs = await client.commandDocs(); + assert.equal(typeof docs, 'object'); + assert.ok(docs !== null); + assert.ok(!Array.isArray(docs)); + + const get = docs['get']; + assert.ok(get); + assert.equal(typeof get.summary, 'string'); + assert.equal(typeof get.since, 'string'); + assert.equal(typeof get.group, 'string'); + assert.equal(typeof get.complexity, 'string'); + assert.ok(Array.isArray(get.arguments)); + assert.ok(get.arguments!.length > 0); + assert.equal(typeof get.arguments![0].name, 'string'); + assert.equal(typeof get.arguments![0].type, 'string'); + }, { + ...GLOBAL.SERVERS.OPEN, + minimumDockerVersion: [7] + }); + + testUtils.testWithClient('client.commandDocs with RESP2 and Map type mapping', async client => { + const docs = await client + .withTypeMapping({ [RESP_TYPES.MAP]: Map }) + .commandDocs('GET'); + + assert.ok(docs instanceof Map); + const get = docs.get('get'); + assert.ok(get instanceof Map); + + const argumentsReply = get.get('arguments'); + assert.ok(Array.isArray(argumentsReply)); + assert.ok(argumentsReply[0] instanceof Map); + assert.equal(argumentsReply[0].get('name'), 'key'); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 2 + }, + minimumDockerVersion: [7] + }); + + testUtils.testWithClient('client.commandDocs with command filter', async client => { + const docs = await client.commandDocs(['GET', 'SET']); + assert.ok('get' in docs); + assert.ok('set' in docs); + assert.equal(Object.keys(docs).length, 2); + }, { + ...GLOBAL.SERVERS.OPEN, + minimumDockerVersion: [7] + }); +}); diff --git a/packages/client/lib/commands/COMMAND_DOCS.ts b/packages/client/lib/commands/COMMAND_DOCS.ts new file mode 100644 index 00000000000..cfee18ec406 --- /dev/null +++ b/packages/client/lib/commands/COMMAND_DOCS.ts @@ -0,0 +1,116 @@ +import { CommandParser } from '../client/parser'; +import { ArrayReply, BlobStringReply, Command, MapReply, NumberReply, ReplyUnion, Resp2Reply, TuplesReply, TypeMapping, UnwrapReply } from '../RESP/types'; +import { RESP_TYPES } from '../RESP/decoder'; +import { RedisVariadicArgument } from './generic-transformers'; + +export interface CommandDocsArgument { + name?: BlobStringReply; + type?: BlobStringReply; + display_text?: BlobStringReply; + key_spec_index?: NumberReply; + token?: BlobStringReply; + summary?: BlobStringReply; + since?: BlobStringReply; + deprecated_since?: BlobStringReply; + flags?: ArrayReply; + arguments?: ArrayReply; +} + +export interface CommandDoc { + summary?: BlobStringReply; + since?: BlobStringReply; + group?: BlobStringReply; + complexity?: BlobStringReply; + module?: BlobStringReply; + doc_flags?: ArrayReply; + deprecated_since?: BlobStringReply; + replaced_by?: BlobStringReply; + history?: ArrayReply>; + arguments?: ArrayReply; + subcommands?: CommandDocsReply; + reply_schema?: ReplyUnion; +} + +export type CommandDocsReply = MapReply; + +function transformMap( + reply: unknown, + typeMapping: TypeMapping | undefined, + transformValue: (key: string, value: unknown) => unknown +): unknown { + if (!Array.isArray(reply)) { + return reply; + } + + const entries: Array<[string, unknown]> = []; + for (let i = 0; i < reply.length; i += 2) { + const key = (reply[i] as { toString(): string }).toString(); + entries.push([key, transformValue(key, reply[i + 1])]); + } + + switch (typeMapping?.[RESP_TYPES.MAP]) { + case Array: + return entries.flat(); + case Map: + return new Map(entries); + default: { + const object: Record = {}; + for (const [key, value] of entries) { + object[key] = value; + } + return object; + } + } +} + +function transformArgument(reply: unknown, typeMapping?: TypeMapping): CommandDocsArgument { + return transformMap(reply, typeMapping, (key, value) => { + if (key === 'arguments' && Array.isArray(value)) { + return value.map(argument => transformArgument(argument, typeMapping)); + } + return value; + }) as CommandDocsArgument; +} + +function transformDoc(reply: unknown, typeMapping?: TypeMapping): CommandDoc { + return transformMap(reply, typeMapping, (key, value) => { + switch (key) { + case 'arguments': + return Array.isArray(value) + ? value.map(argument => transformArgument(argument, typeMapping)) + : value; + case 'subcommands': + return transformDocsMap(value, typeMapping); + case 'reply_schema': + return value; + default: + return value; + } + }) as CommandDoc; +} + +function transformDocsMap(reply: unknown, typeMapping?: TypeMapping): CommandDocsReply { + return transformMap( + reply, + typeMapping, + (_key, value) => transformDoc(value, typeMapping) + ) as CommandDocsReply; +} + +export default { + IS_READ_ONLY: true, + /** + * @see https://redis.io/commands/command-docs/ + */ + parseCommand(parser: CommandParser, commands?: RedisVariadicArgument) { + parser.push('COMMAND', 'DOCS'); + if (commands !== undefined) { + parser.pushVariadic(commands); + } + }, + transformReply: { + 2: (reply: UnwrapReply>, _preserve?: unknown, typeMapping?: TypeMapping) => + transformDocsMap(reply, typeMapping), + 3: undefined as unknown as () => CommandDocsReply + } +} as const satisfies Command; diff --git a/packages/client/lib/commands/index.ts b/packages/client/lib/commands/index.ts index 3ae4f4485ff..2a0e33b4969 100644 --- a/packages/client/lib/commands/index.ts +++ b/packages/client/lib/commands/index.ts @@ -90,6 +90,7 @@ import CLUSTER_SET_CONFIG_EPOCH from './CLUSTER_SET-CONFIG-EPOCH'; import CLUSTER_SETSLOT, { CLUSTER_SLOT_STATES } from './CLUSTER_SETSLOT'; import CLUSTER_SLOTS from './CLUSTER_SLOTS'; import COMMAND_COUNT from './COMMAND_COUNT'; +import COMMAND_DOCS from './COMMAND_DOCS'; import COMMAND_GETKEYS from './COMMAND_GETKEYS'; import COMMAND_GETKEYSANDFLAGS from './COMMAND_GETKEYSANDFLAGS'; import COMMAND_INFO from './COMMAND_INFO'; @@ -1449,6 +1450,18 @@ export default { * @param args - Command arguments to analyze */ commandGetKeysAndFlags: COMMAND_GETKEYSANDFLAGS, + /** + * Returns documentary information about one, multiple, or all Redis commands + * @param commands - Optional command name or names to get documentation for + * @since 7.0.0 + */ + COMMAND_DOCS, + /** + * Returns documentary information about one, multiple, or all Redis commands + * @param commands - Optional command name or names to get documentation for + * @since 7.0.0 + */ + commandDocs: COMMAND_DOCS, /** * Returns details about specific Redis commands * @param commands - Array of command names to get information about