Skip to main content
Glama
reddit-transformers.ts16.4 kB
/** * @file Reddit API response transformation utilities * @module utils/reddit-transformers * * @remarks * This module provides utilities for transforming raw Reddit API responses * into strongly-typed TypeScript objects. It handles data normalization, * type conversion, and provides type guards for runtime validation. * * The Reddit API returns data in various formats with inconsistent types * (e.g., numbers as strings). These transformers ensure consistent, type-safe * data structures throughout the application. * * @see {@link https://www.reddit.com/dev/api/} Reddit API Documentation */ import { z } from 'zod'; import type { RedditNotification as ConfigRedditNotification, RedditMessage, } from '../types/config.js'; import type { RedditPost, RedditComment, RedditSubreddit, RedditNotification as ApiRedditNotification, SubscribedSubreddit, } from '../types/reddit.js'; /** * Raw post data structure from Reddit API * @internal */ interface RedditApiPostData { id: string; title: string; author: string; subreddit: string; selftext?: string; url?: string; score: number | string; created_utc: number | string; num_comments: number | string; permalink: string; [key: string]: unknown; } /** * Raw comment data structure from Reddit API * @internal */ interface RedditApiCommentData { id: string; body: string; author: string; subreddit: string; score: number | string; created_utc: number | string; permalink: string; parent_id: string; link_id: string; [key: string]: unknown; } /** * Raw subreddit data structure from Reddit API * @internal */ interface RedditApiSubredditData { display_name: string; title: string; description?: string; subscribers?: number | string; public_description?: string; submission_type: string; subreddit_type: string; created_utc: number | string; over18: boolean; rules?: Array<{ short_name?: string; title: string; description: string; }>; title_min_length?: number; title_max_length?: number; allowed_title_prefixes?: string[]; banned_title_phrases?: string[]; body_required?: boolean; body_min_length?: number; body_max_length?: number; flair_required?: boolean; [key: string]: unknown; } /** * Raw notification data structure from Reddit API * @internal */ interface RedditApiNotificationData { id: string; subject: string; body: string; author: string; created_utc: number | string; new: boolean; [key: string]: unknown; } /** * Raw message data structure from Reddit API * @internal */ interface RedditApiMessageData { id: string; subject: string; body: string; author: string; dest: string; created_utc: number | string; new: boolean; [key: string]: unknown; } /** * Zod schema for validating Reddit API post data */ const RedditApiPostSchema = z.object({ id: z.string().min(1), title: z.string(), author: z.string().min(1), subreddit: z.string().min(1), permalink: z.string().min(1), score: z.number().optional(), num_comments: z.number().optional(), created_utc: z.number().optional(), selftext: z.string().optional(), url: z.string().optional(), is_self: z.boolean().optional(), over_18: z.boolean().optional(), stickied: z.boolean().optional(), locked: z.boolean().optional(), spoiler: z.boolean().optional(), upvote_ratio: z.number().optional(), }).passthrough(); // Allow additional properties /** * Type guard to check if data is a valid Reddit post * * @param data - Unknown data to validate * @returns True if data matches Reddit post structure * * @example * ```typescript * if (isRedditPostData(response)) { * console.log(response.title); // Type-safe access * } * ``` */ export const isRedditPostData = ( data: unknown, ): data is RedditApiPostData => { return RedditApiPostSchema.safeParse(data).success; }; /** * Transforms raw Reddit API post data into a typed RedditPost object * * @param data - Raw post data from Reddit API * @returns Normalized RedditPost object * @throws {Error} Thrown if data doesn't match expected structure * * @remarks * This function normalizes Reddit API responses by: * - Converting string numbers to actual numbers * - Handling optional fields safely * - Validating required fields * * @example * ```typescript * const rawPost = await fetch('/api/post.json'); * const post = transformPost(rawPost.data); * console.log(post.score); // Guaranteed to be a number * ``` */ export const transformPost = (data: unknown): RedditPost => { if (!isRedditPostData(data)) { throw new Error("Invalid post data received from Reddit API"); } return { id: data.id, title: data.title, author: data.author, subreddit: data.subreddit, selftext: data.selftext || undefined, url: data.url || undefined, score: Number(data.score), createdUtc: Number(data.created_utc), numComments: Number(data.num_comments), permalink: data.permalink, }; }; /** * Type guard to check if data is a valid Reddit comment * * @param data - Unknown data to validate * @returns True if data matches Reddit comment structure */ export const isRedditCommentData = ( data: unknown, ): data is RedditApiCommentData => { const comment = data as Record<string, unknown>; return ( typeof comment?.id === "string" && typeof comment?.author === "string" && typeof comment?.body === "string" && typeof comment?.permalink === "string" ); }; /** * Transforms raw Reddit API comment data into a typed RedditComment object * * @param data - Raw comment data from Reddit API * @returns Normalized RedditComment object * @throws {Error} Thrown if data doesn't match expected structure */ export const transformComment = (data: unknown): RedditComment => { if (!isRedditCommentData(data)) { throw new Error("Invalid comment data received from Reddit API"); } return { id: data.id, author: data.author, body: data.body, score: Number(data.score), createdUtc: Number(data.created_utc), permalink: data.permalink, }; }; /** * Type guard to check if data is a valid Reddit subreddit * * @param data - Unknown data to validate * @returns True if data matches Reddit subreddit structure */ export const isRedditSubredditData = ( data: unknown, ): data is RedditApiSubredditData => { const subreddit = data as Record<string, unknown>; return ( typeof subreddit?.display_name === "string" && typeof subreddit?.title === "string" && typeof subreddit?.public_description === "string" ); }; /** * Transforms raw Reddit API subreddit data into a typed RedditSubreddit object * * @param data - Raw subreddit data from Reddit API * @returns Normalized RedditSubreddit object with rules and requirements * @throws {Error} Thrown if data doesn't match expected structure * * @remarks * This function extracts and normalizes: * - Basic subreddit information * - Posting rules and requirements * - Title and body constraints * - Allowed post types based on submission_type */ export const transformSubreddit = (data: unknown): RedditSubreddit => { if (!isRedditSubredditData(data)) { throw new Error("Invalid subreddit data received from Reddit API"); } const rules = Array.isArray(data.rules) ? data.rules : []; return { displayName: data.display_name, title: data.title, publicDescription: data.public_description || '', subscribers: Number(data.subscribers), createdUtc: Number(data.created_utc), over18: Boolean(data.over18), allowedPostTypes: extractAllowedPostTypes(data), rules: rules.map((rule) => ({ title: rule.short_name || rule.title, description: rule.description, })), postRequirements: { title: { minLength: typeof data.title_min_length === "number" ? data.title_min_length : undefined, maxLength: typeof data.title_max_length === "number" ? data.title_max_length : undefined, allowedPrefixes: Array.isArray(data.allowed_title_prefixes) ? data.allowed_title_prefixes : undefined, bannedPhrases: Array.isArray(data.banned_title_phrases) ? data.banned_title_phrases : undefined, }, body: { required: typeof data.body_required === "boolean" ? data.body_required : undefined, minLength: typeof data.body_min_length === "number" ? data.body_min_length : undefined, maxLength: typeof data.body_max_length === "number" ? data.body_max_length : undefined, }, flairRequired: typeof data.flair_required === "boolean" ? data.flair_required : undefined, }, }; }; /** * Transforms raw Reddit API notification data into a typed notification object * * @param data - Raw notification data from Reddit API * @returns Normalized notification object with proper type classification * * @remarks * This function determines the notification type based on various indicators: * - was_comment field indicates comment/post replies * - parent_id prefix (t3_ for posts, t1_ for comments) * - subject field for messages and mentions * - Handles deleted authors and missing fields gracefully */ export const transformNotification = (data: unknown): ApiRedditNotification => { const notification = data as RedditApiNotificationData; // Determine notification type and subject let type: ApiRedditNotification["type"] = "other"; let subject = String(notification.subject || ""); // For comment replies, we need to check link_title to determine if it's a post or comment reply if ((notification as any).was_comment) { const id = String((notification as any).parent_id || ""); if (id.startsWith("t3_")) { type = "post_reply"; // For post replies, use the post title as subject subject = String((notification as any).link_title || "Comment on your post"); } else { type = "comment_reply"; // For comment replies, use a descriptive subject subject = "Reply to your comment"; } } else if (notification.subject === "username mention") { type = "username_mention"; subject = "Username mention"; } else if (notification.subject && !(notification as any).was_comment) { type = "message"; // For messages, use the original subject subject = String(notification.subject); } return { id: String((notification as any).name || ""), // name contains the full ID with prefix name: String((notification as any).name || ""), type, subject, body: String(notification.body || ""), createdUtc: Number(notification.created_utc || 0), date: (notification as any).date instanceof Date ? (notification as any).date : new Date(), author: String(notification.author || "[deleted]"), subreddit: typeof (notification as any).subreddit === "string" ? (notification as any).subreddit : undefined, context: typeof (notification as any).context === "string" ? (notification as any).context : undefined, parentId: typeof (notification as any).parent_id === "string" ? (notification as any).parent_id : undefined, isNew: Boolean(notification.new), permalink: typeof (notification as any).permalink === "string" ? (notification as any).permalink : ((notification as any).context as string | undefined), }; }; /** * Transforms raw Reddit API message data into a typed RedditMessage object * * @param data - Raw message data from Reddit API * @returns Normalized RedditMessage object * * @remarks * Handles private messages with proper field mapping and default values */ export const transformMessage = (data: unknown): RedditMessage => { const message = data as RedditApiMessageData; return { id: String((message as any).name || ""), type: "message", subject: String(message.subject || ""), parent_id: String((message as any).parent_id || (message as any).name || ""), author: String(message.author || "[deleted]"), body: String(message.body || ""), created_utc: Number(message.created_utc || 0), unread: Boolean(message.new), }; }; /** * Converts an API notification to the config notification format * * @param notification - API notification object * @returns Config-formatted notification or message * * @remarks * This function bridges the gap between Reddit API format and the * MCP server's internal config format, handling both messages and * notifications with appropriate field mapping. */ export function transformToConfigNotification( notification: ApiRedditNotification, ): ConfigRedditNotification | RedditMessage { if (notification.type === "message") { return { id: notification.id, type: "message", subject: notification.subject, parent_id: notification.parentId || notification.id, author: notification.author, body: notification.body, created_utc: notification.createdUtc, unread: notification.isNew, }; } return { id: notification.id, type: notification.type, subject: notification.subject, parent_id: notification.parentId || "", subreddit: notification.subreddit || "", author: notification.author, body: notification.body || "", created_utc: notification.createdUtc, permalink: notification.permalink || "", unread: notification.isNew, }; } /** * Extracts allowed post types from subreddit submission_type field * * @param data - Raw subreddit data * @returns Array of allowed post types * * @remarks * Reddit's submission_type field can be: * - "any": Both text and link posts allowed * - "self": Only text posts allowed * - "link": Only link posts allowed * * @internal */ const extractAllowedPostTypes = (data: unknown): string[] => { const types: string[] = []; const subredditData = data as Record<string, unknown>; if (subredditData.submission_type === "any" || subredditData.submission_type === "self") { types.push("text"); } if (subredditData.submission_type === "any" || subredditData.submission_type === "link") { types.push("link"); } return types.length > 0 ? types : ["text", "link"]; }; /** * Type guard to check if data is a valid subscribed subreddit * * @param data - Unknown data to validate * @returns True if data matches subscribed subreddit structure * * @remarks * This guard is more strict than general subreddit data as it validates * fields that are guaranteed to be present for subscribed subreddits. */ export const isSubscribedSubredditData = ( data: unknown, ): data is { id: string; name: string; display_name: string; title: string; public_description: string; subscribers: number; over18: boolean; url: string; icon_img?: string; created_utc: number; subreddit_type: string; } => { const subreddit = data as Record<string, unknown>; return ( typeof subreddit?.id === "string" && typeof subreddit?.name === "string" && typeof subreddit?.display_name === "string" && typeof subreddit?.title === "string" && typeof subreddit?.public_description === "string" && typeof subreddit?.subscribers === "number" && typeof subreddit?.over18 === "boolean" && typeof subreddit?.url === "string" && typeof subreddit?.created_utc === "number" && typeof subreddit?.subreddit_type === "string" ); }; /** * Transforms raw Reddit API subscribed subreddit data into a typed object * * @param data - Raw subscribed subreddit data from Reddit API * @returns Normalized SubscribedSubreddit object * @throws {Error} Thrown if data doesn't match expected structure * * @example * ```typescript * const subData = await fetch('/subreddits/mine/subscriber.json'); * const subreddit = transformSubscribedSubreddit(subData.data); * console.log(`Subscribed to: ${subreddit.displayName}`); * ``` */ export const transformSubscribedSubreddit = (data: unknown): SubscribedSubreddit => { if (!isSubscribedSubredditData(data)) { throw new Error("Invalid subscribed subreddit data received from Reddit API"); } return { id: data.id, name: data.name, displayName: data.display_name, title: data.title, subscribers: data.subscribers, description: data.public_description, isNsfw: data.over18, url: data.url, icon: data.icon_img, createdUtc: data.created_utc, type: data.subreddit_type, }; };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/systempromptio/systemprompt-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server