FeedFabric docsGitHub

API client

Types that match the API's JSON, one request function that signs every call and turns Problem Details into errors with a stable code, and the endpoints a member app uses.

Types

The API speaks JSON with camelCase names. IDs are UUID strings, times are ISO 8601 strings in UTC, and absent values are null rather than missing. These match the C# response classes (HomeFeedPostResponse, ForYouResponse, CommentResponse, MediaResponse and friends in src/FeedFabric.Application/Services).

// src/types.ts
export interface Tag { tag: string; source: 'hashtag' | 'inferred' }

// Signed, short-lived URLs (an hour by default): use as given, never store
export interface PostMedia { id: string; url: string; thumbnailUrl: string; width: number; height: number }

export interface RankingFactor { label: string; value: number; detail: string }

export interface PostRanking {
  ranker: string               // "model:<version>", "heuristic", "expression…" or "newest"
  position: number             // 0-based place in this For You session
  score: number
  explored: boolean            // in an exploration slot rather than earned by score
  factors: RankingFactor[]     // the reasons, in words
  features: Record<string, number>
}

export interface FeedPost {
  id: string
  authorId: string
  authorExternalUserId: string
  authorUsername: string
  content: string              // "" on a repost
  createdAt: string
  editedAt: string | null
  repostOfId: string | null
  repostOf: FeedPost | null    // on a repost: the post it shares
  isHeld: boolean              // held for review: only its author is ever shown it
  tags: Tag[]
  media: PostMedia[]
  ranking: PostRanking | null  // For You only
}

export interface HomeFeed { posts: FeedPost[]; nextCursor: string | null; source: 'redis' | 'postgres' | 'redis+postgres' }

export interface ForYouFeed {
  posts: FeedPost[]
  nextCursor: string | null    // an offset such as "20": pass it back unchanged
  ranker: string
  profile: string              // "balanced", or "running-club@3" for a saved profile
  candidates: number
}

export interface CreatedPost {
  id: string; authorId: string; content: string; createdAt: string; editedAt: string | null
  repostOfId: string | null; isHeld: boolean
}

export interface Comment {
  id: string; postId: string; parentCommentId: string | null
  authorId: string; authorExternalUserId: string; authorUsername: string
  content: string | null       // null when deleted or removed
  isDeleted: boolean; isHeld: boolean
  createdAt: string
  replyCount: number           // direct, visible replies
}
export interface CommentPage { comments: Comment[]; nextCursor: string | null }

export interface Mention {
  id: string; createdAt: string; authorExternalUserId: string; authorUsername: string
  postId: string; commentId: string | null; content: string
}
export interface MentionPage { mentions: Mention[]; nextCursor: string | null }

export interface Favourite { externalUserId: string; username: string; displayName: string | null; avatarUrl: string | null; addedAt: string }
export interface TrendingTag { tag: string; posts: number }

export interface Upload { mediaId: string; uploadUrl: string; contentType: string; expiresAt: string }
export interface Media {
  id: string
  status: 'awaitingUpload' | 'processing' | 'ready' | 'rejected'
  rejectionReason: string | null
  width: number | null; height: number | null
  url: string | null; thumbnailUrl: string | null
}

export type ReportReason = 'spam' | 'harassment' | 'hate' | 'threat' | 'sexual' | 'self_harm' | 'other'

Errors: Problem Details with a code

Every error, including auth, routing and unhandled exceptions, is RFC 9457 Problem Details (application/problem+json) with two additions: code, always present and stable, and params, the values a message needs. detail is English for developers and logs: don’t show it to members or match on it.

{ "status": 409, "title": "Conflict", "detail": "Posts can only be edited within 60 minutes of posting.",
  "code": "post.edit_window_closed", "params": { "editWindowMinutes": 60 }, "traceId": "00-…" }

Validation problems (validation_failed, and profile.invalid for ranking profiles) list each one in errors:

{ "code": "validation_failed",
  "errors": [ { "field": "content", "code": "required" },
              { "field": "limit", "code": "out_of_range", "params": { "min": 1, "max": 100 } } ] }

Turn all of that into one error type, and keep your own message per code (translated, if you translate). Codes are never renamed but new ones may be added, so always have a fallback. The full list is in Errors.

// src/api/errors.ts
export interface FieldError { field?: string; code: string; params?: Record<string, unknown> }

export class ApiError extends Error {
  constructor(
    readonly status: number,
    readonly code: string,
    readonly params: Record<string, unknown> = {},
    readonly errors: FieldError[] = [],
    readonly traceId?: string,
  ) {
    super(`${status} ${code}`)
  }
}

const messages: Record<string, (p: Record<string, unknown>) => string> = {
  'user.suspended': () => "Your account is suspended, so you can read but not post or interact for now.",
  'post.not_found': () => 'That post has been deleted.',
  'comment.not_found': () => 'That comment has been deleted.',
  'post.edit_window_closed': p => `Posts can only be edited for ${p.editWindowMinutes} minutes.`,
  'content.violation': p => `This breaks our community rules (${String(p.category).replaceAll('_', ' ')}).`,
  'media.type_not_allowed': () => 'Photos must be JPEG, PNG or WebP.',
  'media.too_large': () => 'That photo is too big.',
  'media.not_ready': () => 'A photo is still being checked, or was rejected. Remove it or wait.',
  'post.too_many_media': () => 'Too many photos on one post.',
  'favourite.limit_reached': p => `You can have up to ${p.max} favourites. Remove one first.`,
  'unauthorized': () => 'Please sign in again.',
}

export function describe(e: unknown): string {
  if (e instanceof ApiError) return messages[e.code]?.(e.params) ?? 'Something went wrong. Please try again.'
  if (e instanceof TypeError) return "Couldn't reach the server. Check your connection."
  return 'Something went wrong. Please try again.'
}

Signing in

The client needs one thing from your sign-in code: a function that returns a current access token for FeedFabric’s audience, refreshing it when needed. Your identity provider’s SDK provides that (getAccessTokenSilently in Auth0’s SPA SDK, acquireTokenSilent in MSAL, getUser() in oidc-client-ts).

// src/api/auth.ts
export type TokenProvider = () => Promise<string | null>

export const auth = {
  // Your SDK's "get a fresh access token", e.g. () => auth0.getAccessTokenSilently()
  getToken: (async () => null) as TokenProvider,
  // Development only: act as this user when there's no token (sent as X-Dev-User)
  devUser: import.meta.env.DEV ? 'sim-2' : null as string | null,
  // Called on a 401: send the member to sign in again
  onUnauthorized: () => {},
}

In Development (Auth:Mode Dev) the API trusts X-Dev-User and unsigned tokens; nothing else will work in production, where it’s Jwt and needs a real token. See Sign-in and users.

One request function

// src/api/request.ts
import { ApiError } from './errors'
import { auth } from './auth'

export async function request<T>(path: string, init: { method?: string; body?: unknown; keepalive?: boolean } = {}): Promise<T> {
  const headers: Record<string, string> = {}
  const token = await auth.getToken()
  if (token) headers.Authorization = `Bearer ${token}`
  else if (auth.devUser) headers['X-Dev-User'] = auth.devUser
  if (init.body !== undefined) headers['Content-Type'] = 'application/json'

  const res = await fetch(path, {
    method: init.method ?? 'GET',
    headers,
    body: init.body === undefined ? undefined : JSON.stringify(init.body),
    keepalive: init.keepalive,
  })

  if (!res.ok) {
    const problem = await res.json().catch(() => ({}))
    if (res.status === 401) auth.onUnauthorized()
    throw new ApiError(res.status, problem.code ?? `http_${res.status}`, problem.params, problem.errors, problem.traceId)
  }
  // 204 has no body; 201 and 202 do
  return (res.status === 204 ? undefined : await res.json()) as T
}

The endpoints

// src/api/index.ts
import { request } from './request'
import type {
  CommentPage, Comment, CreatedPost, Favourite, ForYouFeed, HomeFeed, Media, MentionPage, ReportReason, TrendingTag, Upload,
} from '../types'

const q = (params: Record<string, string | number | null | undefined>) => {
  const s = new URLSearchParams()
  for (const [k, v] of Object.entries(params)) if (v !== null && v !== undefined && v !== '') s.set(k, String(v))
  const text = s.toString()
  return text ? `?${text}` : ''
}
const id = encodeURIComponent

export const api = {
  // Feeds: newest first, or ranked; pass the previous page's nextCursor back as cursor
  home: (cursor?: string | null, limit = 20) => request<HomeFeed>(`/userfeed/home${q({ cursor, limit })}`),
  forYou: (cursor?: string | null, profile?: string | null, limit = 20) =>
    request<ForYouFeed>(`/userfeed/foryou${q({ cursor, profile, limit })}`),
  favouritesFeed: (cursor?: string | null) => request<HomeFeed>(`/userfeed/favourites${q({ cursor })}`),
  tagPosts: (tag: string, cursor?: string | null) => request<HomeFeed>(`/userfeed/tags/${id(tag)}/posts${q({ cursor })}`),
  trending: (hours = 24, limit = 10) => request<TrendingTag[]>(`/userfeed/tags/trending${q({ hours, limit })}`),
  mentions: (cursor?: string | null) => request<MentionPage>(`/userfeed/mentions${q({ cursor })}`),

  // Posts
  createPost: (content: string, mediaIds: string[] = []) => request<CreatedPost>('/userfeed/posts', { method: 'POST', body: { content, mediaIds } }),
  editPost: (postId: string, content: string) => request<CreatedPost>(`/userfeed/posts/${id(postId)}`, { method: 'PUT', body: { content } }),
  deletePost: (postId: string) => request<void>(`/userfeed/posts/${id(postId)}`, { method: 'DELETE' }),

  // Engagement: PUT and DELETE are idempotent, so repeating one is harmless
  like: (postId: string) => request<void>(`/userfeed/posts/${id(postId)}/like`, { method: 'PUT' }),
  unlike: (postId: string) => request<void>(`/userfeed/posts/${id(postId)}/like`, { method: 'DELETE' }),
  repost: (postId: string) => request<void>(`/userfeed/posts/${id(postId)}/repost`, { method: 'PUT' }),
  unrepost: (postId: string) => request<void>(`/userfeed/posts/${id(postId)}/repost`, { method: 'DELETE' }),
  follow: (externalUserId: string) => request<void>(`/follows/${id(externalUserId)}`, { method: 'PUT' }),
  unfollow: (externalUserId: string) => request<void>(`/follows/${id(externalUserId)}`, { method: 'DELETE' }),
  favourites: () => request<Favourite[]>('/favourites'),
  addFavourite: (externalUserId: string) => request<void>(`/favourites/${id(externalUserId)}`, { method: 'PUT' }),
  removeFavourite: (externalUserId: string) => request<void>(`/favourites/${id(externalUserId)}`, { method: 'DELETE' }),

  // Comments
  comments: (postId: string, cursor?: string | null) => request<CommentPage>(`/userfeed/posts/${id(postId)}/comments${q({ cursor })}`),
  replies: (commentId: string, cursor?: string | null) => request<CommentPage>(`/userfeed/comments/${id(commentId)}/replies${q({ cursor })}`),
  comment: (postId: string, content: string, parentCommentId: string | null = null) =>
    request<Comment>(`/userfeed/posts/${id(postId)}/comments`, { method: 'POST', body: { content, parentCommentId } }),
  deleteComment: (commentId: string) => request<void>(`/userfeed/comments/${id(commentId)}`, { method: 'DELETE' }),

  // Reports
  reportPost: (postId: string, reason: ReportReason) => request<void>(`/userfeed/posts/${id(postId)}/report`, { method: 'POST', body: { reason } }),
  reportComment: (commentId: string, reason: ReportReason) =>
    request<void>(`/userfeed/comments/${id(commentId)}/report`, { method: 'POST', body: { reason } }),

  // Reading signals (see "Reading signals"); keepalive lets the last batch out as the page closes
  impressions: (postIds: string[], keepalive = false) =>
    request<{ counted: number }>('/userfeed/impressions', { method: 'POST', body: { postIds }, keepalive }),
  dwell: (views: { postId: string; ms: number }[], keepalive = false) =>
    request<{ counted: number }>('/userfeed/dwell', { method: 'POST', body: { views }, keepalive }),
  open: (postId: string) => request<{ counted: boolean }>(`/userfeed/posts/${id(postId)}/open`, { method: 'POST' }),
  notInterested: (postId: string) => request<void>(`/userfeed/posts/${id(postId)}/not-interested`, { method: 'POST' }),
  showMore: (postId: string) => request<void>(`/userfeed/posts/${id(postId)}/show-more`, { method: 'POST' }),

  // Photos (see "Composing with photos")
  createUpload: (contentType: string, size: number) => request<Upload>('/media/uploads', { method: 'POST', body: { contentType, size } }),
  completeUpload: (mediaId: string) => request<Media>(`/media/${id(mediaId)}/complete`, { method: 'POST' }),
  media: (mediaId: string) => request<Media>(`/media/${id(mediaId)}`),
}

Handling errors well

Situation What to do
401 unauthorized Token missing, expired or for the wrong audience: refresh it or sign in again.
403 user.suspended Switch the app to read-only (hide composer and actions) and say why. Reading and reading signals still work.
404 user.not_synced The member’s user wasn’t created yet (provisioning off, or a race on the very first request). Retry once; if it persists your back end needs to create users.
404 post.not_found / comment.not_found It was deleted (or you used a repost’s own ID). Remove it from the screen.
422 content.violation Keep the member’s text, show the rule (params.category), let them edit.
validation_failed Show each errors[].code next to its field.
500 internal_error Show a generic message; log traceId, which is what the server’s logs are keyed on.
Network error (TypeError from fetch) Offline or the server is down: offer to retry.

Fire-and-forget calls (signals, likes after an optimistic update) should catch their own errors so a failure never surfaces as an unhandled rejection.

Edit this page on GitHub

FeedFabric is MIT licensed and open source on GitHub. These pages are built fromweb/docs; the repository's own docs/ folder has the full configuration, data model and error lists. Found something wrong? Fix it oropen an issue.