From 92a848d600d9c1ce6ce61ef682189f1272dc6c04 Mon Sep 17 00:00:00 2001 From: Bretton Date: Wed, 18 Feb 2026 23:16:57 -0800 Subject: [PATCH] feat(api): implement Coves XRPC client with typed API methods Add a complete XRPC transport layer and typed API client for the Coves backend, replacing the need for Lemmy/PiFed adapters for Coves-native endpoints. Changes: - Add XrpcClient with query (GET) and procedure (POST) methods that parse structured XRPC error bodies into typed XrpcError instances - Add CovesClient with typed methods for all 18 Coves API endpoints (feed, comments, votes, actor, community, posts) - Add covesCustomFetch that returns raw Response (unlike customFetch) so XrpcClient can parse XRPC error bodies before throwing - Add coves() factory function integrated with profile/proxy routing - Strengthen types: add PostRecord, CommentRecord, CommunityVisibility union type, and replace generic string types with literal unions - Add 46 tests covering XRPC transport, client delegation, and type validation utilities Co-Authored-By: Claude Opus 4.6 --- src/lib/api/client.svelte.ts | 87 +++++++- src/lib/api/coves/client.test.ts | 277 +++++++++++++++++++++++ src/lib/api/coves/client.ts | 170 ++++++++++++++ src/lib/api/coves/index.ts | 3 + src/lib/api/coves/types.test.ts | 145 ++++++++++++ src/lib/api/coves/types.ts | 51 ++++- src/lib/api/coves/xrpc.test.ts | 366 +++++++++++++++++++++++++++++++ src/lib/api/coves/xrpc.ts | 110 ++++++++++ 8 files changed, 1195 insertions(+), 14 deletions(-) create mode 100644 src/lib/api/coves/client.test.ts create mode 100644 src/lib/api/coves/client.ts create mode 100644 src/lib/api/coves/index.ts create mode 100644 src/lib/api/coves/types.test.ts create mode 100644 src/lib/api/coves/xrpc.test.ts create mode 100644 src/lib/api/coves/xrpc.ts diff --git a/src/lib/api/client.svelte.ts b/src/lib/api/client.svelte.ts index d7b07dab..eb84b552 100644 --- a/src/lib/api/client.svelte.ts +++ b/src/lib/api/client.svelte.ts @@ -4,6 +4,7 @@ import { DEFAULT_INSTANCE_URL } from '$lib/app/instance.svelte' import { instanceToURL } from '$lib/app/util.svelte' import { error } from '@sveltejs/kit' import { BaseClient, DEFAULT_CLIENT_TYPE, type ClientType } from './base' +import { CovesClient } from './coves' import { LemmyClient } from './lemmy/adapter' import { PiefedClient } from './piefed/adapter' import type { GetSiteResponse } from './types' @@ -39,18 +40,16 @@ function toProxyUrl(input: RequestInfo | URL): RequestInfo | URL { const parsed = new URL(url) // Convert to proxy path: /api/proxy/{path} const proxyPath = `/api/proxy${parsed.pathname}${parsed.search}` - return input instanceof Request - ? new Request(proxyPath, input) - : proxyPath + return input instanceof Request ? new Request(proxyPath, input) : proxyPath } catch (err) { // URL parsing failure indicates a malformed URL - this should not happen // in normal operation and could indicate a security issue or bug console.error( '[client] Failed to parse URL for proxy routing - aborting request:', - { url, error: err instanceof Error ? err.message : String(err) } + { url, error: err instanceof Error ? err.message : String(err) }, ) throw new Error( - `Invalid URL for API request: ${err instanceof Error ? err.message : String(err)}` + `Invalid URL for API request: ${err instanceof Error ? err.message : String(err)}`, ) } } @@ -161,6 +160,82 @@ export function client({ ) } +/** + * Custom fetch for the Coves XRPC client. + * + * Unlike `customFetch`, this does NOT throw on non-ok responses. Instead it + * returns the raw Response so that XrpcClient can parse structured XRPC error + * bodies and throw typed `XrpcError` instances. + */ +async function covesCustomFetch( + func: + | (( + input: RequestInfo | URL, + init?: RequestInit | undefined, + ) => Promise) + | undefined, + input: RequestInfo | URL, + init?: RequestInit | undefined, + auth?: string, +): Promise { + const f = func ?? fetch + + const headers: Record = { + ...(init?.headers as Record), + 'User-Agent': `Photon/${__VERSION__}`, + } + + if (browser) { + const proxyInput = toProxyUrl(input) + const proxyInit: RequestInit = { + ...init, + headers, + credentials: 'include', + } + + if (profile.isAuthenticated) { + proxyInit.cache = 'no-store' + } + + return f(proxyInput, proxyInit) + } else { + if (auth) { + headers['Authorization'] = `Bearer ${auth}` + } + + const serverInit: RequestInit = { + ...init, + headers, + } + + if (auth) { + serverInit.cache = 'no-store' + } + + return f(input, serverInit) + } +} + +export function coves({ + instanceURL, + func, + auth, +}: { + instanceURL?: string + func?: typeof fetch + auth?: string +} = {}): CovesClient { + if (!instanceURL) + instanceURL = profile.current.instance || DEFAULT_INSTANCE_URL + + const baseUrl = instanceToURL(instanceURL) + + return new CovesClient({ + baseUrl, + fetchFn: (input, init) => covesCustomFetch(func, input, init, auth), + }) +} + // here for parts where i forgor to switch export function getClient( instanceURL?: string, @@ -204,7 +279,7 @@ export async function validateInstance( '[validateInstance] Validation failed for instance:', { instance, clientType: type?.name ?? 'default' }, 'Error:', - errorMessage + errorMessage, ) return { valid: false, error: errorMessage } } diff --git a/src/lib/api/coves/client.test.ts b/src/lib/api/coves/client.test.ts new file mode 100644 index 00000000..3c8395af --- /dev/null +++ b/src/lib/api/coves/client.test.ts @@ -0,0 +1,277 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { CovesClient, NSID } from './client' +import { XrpcClient } from './xrpc' +import type { DID } from '$lib/types/atproto' +import type { AtUri, CID } from './types' + +// --------------------------------------------------------------------------- +// Setup: spy on XrpcClient prototype methods +// --------------------------------------------------------------------------- + +let querySpy: ReturnType +let procedureSpy: ReturnType +let client: CovesClient + +beforeEach(() => { + querySpy = vi.spyOn(XrpcClient.prototype, 'query').mockResolvedValue({}) + procedureSpy = vi + .spyOn(XrpcClient.prototype, 'procedure') + .mockResolvedValue(undefined) + client = new CovesClient({ + fetchFn: globalThis.fetch, + baseUrl: 'https://api.coves.social', + }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +// --------------------------------------------------------------------------- +// Feed methods +// --------------------------------------------------------------------------- + +describe('Feed methods', () => { + it('getDiscover() calls query with correct NSID', async () => { + await client.getDiscover({ limit: 20 }) + + expect(querySpy).toHaveBeenCalledWith(NSID.getDiscover, { + limit: 20, + }) + }) + + it('getTimeline() calls query with correct NSID', async () => { + await client.getTimeline({ cursor: 'abc' }) + + expect(querySpy).toHaveBeenCalledWith(NSID.getTimeline, { + cursor: 'abc', + }) + }) + + it('getCommunityFeed() calls query with correct NSID', async () => { + await client.getCommunityFeed({ community: 'tech' }) + + expect(querySpy).toHaveBeenCalledWith(NSID.getCommunityFeed, { + community: 'tech', + }) + }) +}) + +// --------------------------------------------------------------------------- +// Comment methods +// --------------------------------------------------------------------------- + +describe('Comment methods', () => { + it('getComments() calls query with correct NSID', async () => { + await client.getComments({ post: 'at://did:plc:abc/post/1' }) + + expect(querySpy).toHaveBeenCalledWith(NSID.getComments, { + post: 'at://did:plc:abc/post/1', + }) + }) + + it('createComment() calls procedure with correct NSID', async () => { + const input = { + reply: { + root: { + uri: 'at://did:plc:abc/post/1' as AtUri, + cid: 'bafy1' as CID, + }, + parent: { + uri: 'at://did:plc:abc/post/1' as AtUri, + cid: 'bafy1' as CID, + }, + }, + content: 'Great post!', + } + await client.createComment(input) + + expect(procedureSpy).toHaveBeenCalledWith(NSID.createComment, input) + }) + + it('deleteComment() calls procedure with correct NSID', async () => { + await client.deleteComment({ + uri: 'at://did:plc:abc/comment/1' as AtUri, + }) + + expect(procedureSpy).toHaveBeenCalledWith(NSID.deleteComment, { + uri: 'at://did:plc:abc/comment/1', + }) + }) +}) + +// --------------------------------------------------------------------------- +// Vote methods +// --------------------------------------------------------------------------- + +describe('Vote methods', () => { + it('createVote() calls procedure with correct NSID', async () => { + const input = { + subject: { + uri: 'at://did:plc:abc/post/1' as AtUri, + cid: 'bafy1' as CID, + }, + direction: 'up' as const, + } + await client.createVote(input) + + expect(procedureSpy).toHaveBeenCalledWith(NSID.createVote, input) + }) + + it('deleteVote() calls procedure with correct NSID', async () => { + const input = { + subject: { + uri: 'at://did:plc:abc/post/1' as AtUri, + cid: 'bafy1' as CID, + }, + } + await client.deleteVote(input) + + expect(procedureSpy).toHaveBeenCalledWith(NSID.deleteVote, input) + }) +}) + +// --------------------------------------------------------------------------- +// Actor methods +// --------------------------------------------------------------------------- + +describe('Actor methods', () => { + it('getProfile() calls query with correct NSID', async () => { + await client.getProfile({ actor: 'did:plc:abc123' }) + + expect(querySpy).toHaveBeenCalledWith(NSID.getProfile, { + actor: 'did:plc:abc123', + }) + }) + + it('getActorPosts() calls query with correct NSID', async () => { + await client.getActorPosts({ actor: 'did:plc:abc123', limit: 10 }) + + expect(querySpy).toHaveBeenCalledWith(NSID.getActorPosts, { + actor: 'did:plc:abc123', + limit: 10, + }) + }) + + it('getActorComments() calls query with correct NSID', async () => { + await client.getActorComments({ actor: 'did:plc:abc123' }) + + expect(querySpy).toHaveBeenCalledWith(NSID.getActorComments, { + actor: 'did:plc:abc123', + }) + }) + + it('blockUser() calls procedure with correct NSID', async () => { + await client.blockUser({ did: 'did:plc:abc123' as DID }) + + expect(procedureSpy).toHaveBeenCalledWith(NSID.blockUser, { + did: 'did:plc:abc123', + }) + }) + + it('unblockUser() calls procedure with correct NSID', async () => { + await client.unblockUser({ did: 'did:plc:abc123' as DID }) + + expect(procedureSpy).toHaveBeenCalledWith(NSID.unblockUser, { + did: 'did:plc:abc123', + }) + }) +}) + +// --------------------------------------------------------------------------- +// Community methods +// --------------------------------------------------------------------------- + +describe('Community methods', () => { + it('getCommunity() calls query with correct NSID', async () => { + await client.getCommunity({ community: 'tech' }) + + expect(querySpy).toHaveBeenCalledWith(NSID.getCommunity, { + community: 'tech', + }) + }) + + it('listCommunities() calls query with correct NSID', async () => { + await client.listCommunities({ limit: 25 }) + + expect(querySpy).toHaveBeenCalledWith(NSID.listCommunities, { + limit: 25, + }) + }) + + it('searchCommunities() calls query with correct NSID', async () => { + await client.searchCommunities({ query: 'rust' }) + + expect(querySpy).toHaveBeenCalledWith(NSID.searchCommunities, { + query: 'rust', + }) + }) + + it('createCommunity() calls procedure with correct NSID', async () => { + const input = { + name: 'rust', + description: 'Rust programming language', + visibility: 'public' as const, + } + await client.createCommunity(input) + + expect(procedureSpy).toHaveBeenCalledWith(NSID.createCommunity, input) + }) + + it('subscribe() calls procedure with correct NSID', async () => { + await client.subscribe({ community: 'tech' }) + + expect(procedureSpy).toHaveBeenCalledWith(NSID.subscribe, { + community: 'tech', + }) + }) + + it('unsubscribe() calls procedure with correct NSID', async () => { + await client.unsubscribe({ community: 'tech' }) + + expect(procedureSpy).toHaveBeenCalledWith(NSID.unsubscribe, { + community: 'tech', + }) + }) + + it('blockCommunity() calls procedure with correct NSID', async () => { + await client.blockCommunity({ community: 'spam' }) + + expect(procedureSpy).toHaveBeenCalledWith(NSID.blockCommunity, { + community: 'spam', + }) + }) + + it('unblockCommunity() calls procedure with correct NSID', async () => { + await client.unblockCommunity({ community: 'spam' }) + + expect(procedureSpy).toHaveBeenCalledWith(NSID.unblockCommunity, { + community: 'spam', + }) + }) +}) + +// --------------------------------------------------------------------------- +// Post methods +// --------------------------------------------------------------------------- + +describe('Post methods', () => { + it('createPost() calls procedure with correct NSID', async () => { + const input = { + community: 'tech', + title: 'Hello World', + content: 'My first post', + } + await client.createPost(input) + + expect(procedureSpy).toHaveBeenCalledWith(NSID.createPost, input) + }) + + it('deletePost() calls procedure with correct NSID', async () => { + await client.deletePost({ uri: 'at://did:plc:abc/post/1' as AtUri }) + + expect(procedureSpy).toHaveBeenCalledWith(NSID.deletePost, { + uri: 'at://did:plc:abc/post/1', + }) + }) +}) diff --git a/src/lib/api/coves/client.ts b/src/lib/api/coves/client.ts new file mode 100644 index 00000000..f519aef8 --- /dev/null +++ b/src/lib/api/coves/client.ts @@ -0,0 +1,170 @@ +import { XrpcClient } from './xrpc' +import type { + AtUri, + BlockCommunityInput, + BlockUserInput, + CommunityViewDetailed, + CreateCommentInput, + CreateCommentOutput, + CreateCommunityInput, + CreatePostInput, + CreatePostOutput, + CreateVoteInput, + CreateVoteOutput, + DeleteVoteInput, + FeedResponse, + GetActorCommentsParams, + GetActorCommentsResponse, + GetActorPostsParams, + GetActorPostsResponse, + GetCommentsParams, + GetCommentsResponse, + GetCommunityFeedParams, + GetCommunityParams, + GetDiscoverParams, + GetProfileParams, + GetTimelineParams, + ListCommunitiesParams, + ListCommunitiesResponse, + ProfileViewDetailed, + SearchCommunitiesParams, + SubscribeCommunityInput, +} from './types' + +export const NSID = { + getDiscover: 'social.coves.feed.getDiscover', + getTimeline: 'social.coves.feed.getTimeline', + getCommunityFeed: 'social.coves.feed.getCommunity', + getComments: 'social.coves.community.comment.getComments', + createComment: 'social.coves.community.comment.create', + deleteComment: 'social.coves.community.comment.delete', + createVote: 'social.coves.feed.vote.create', + deleteVote: 'social.coves.feed.vote.delete', + getProfile: 'social.coves.actor.getProfile', + getActorPosts: 'social.coves.actor.getPosts', + getActorComments: 'social.coves.actor.getComments', + blockUser: 'social.coves.actor.blockUser', + unblockUser: 'social.coves.actor.unblockUser', + getCommunity: 'social.coves.community.get', + listCommunities: 'social.coves.community.list', + searchCommunities: 'social.coves.community.search', + createCommunity: 'social.coves.community.create', + subscribe: 'social.coves.community.subscribe', + unsubscribe: 'social.coves.community.unsubscribe', + blockCommunity: 'social.coves.community.blockCommunity', + unblockCommunity: 'social.coves.community.unblockCommunity', + createPost: 'social.coves.community.post.create', + deletePost: 'social.coves.community.post.delete', +} as const + +export class CovesClient { + private xrpc: XrpcClient + + constructor(options: { fetchFn: typeof fetch; baseUrl: string }) { + this.xrpc = new XrpcClient(options) + } + + // Feed + getDiscover(params?: GetDiscoverParams): Promise { + return this.xrpc.query(NSID.getDiscover, params) + } + + getTimeline(params?: GetTimelineParams): Promise { + return this.xrpc.query(NSID.getTimeline, params) + } + + getCommunityFeed(params: GetCommunityFeedParams): Promise { + return this.xrpc.query(NSID.getCommunityFeed, params) + } + + // Comments + getComments(params: GetCommentsParams): Promise { + return this.xrpc.query(NSID.getComments, params) + } + + createComment(input: CreateCommentInput): Promise { + return this.xrpc.procedure(NSID.createComment, input) + } + + deleteComment(input: { uri: AtUri }): Promise { + return this.xrpc.procedure(NSID.deleteComment, input) + } + + // Votes + createVote(input: CreateVoteInput): Promise { + return this.xrpc.procedure(NSID.createVote, input) + } + + deleteVote(input: DeleteVoteInput): Promise { + return this.xrpc.procedure(NSID.deleteVote, input) + } + + // Actor + getProfile(params: GetProfileParams): Promise { + return this.xrpc.query(NSID.getProfile, params) + } + + getActorPosts(params: GetActorPostsParams): Promise { + return this.xrpc.query(NSID.getActorPosts, params) + } + + getActorComments( + params: GetActorCommentsParams, + ): Promise { + return this.xrpc.query(NSID.getActorComments, params) + } + + blockUser(input: BlockUserInput): Promise { + return this.xrpc.procedure(NSID.blockUser, input) + } + + unblockUser(input: BlockUserInput): Promise { + return this.xrpc.procedure(NSID.unblockUser, input) + } + + // Community + getCommunity(params: GetCommunityParams): Promise { + return this.xrpc.query(NSID.getCommunity, params) + } + + listCommunities( + params?: ListCommunitiesParams, + ): Promise { + return this.xrpc.query(NSID.listCommunities, params) + } + + searchCommunities( + params: SearchCommunitiesParams, + ): Promise { + return this.xrpc.query(NSID.searchCommunities, params) + } + + createCommunity(input: CreateCommunityInput): Promise { + return this.xrpc.procedure(NSID.createCommunity, input) + } + + subscribe(input: SubscribeCommunityInput): Promise { + return this.xrpc.procedure(NSID.subscribe, input) + } + + unsubscribe(input: SubscribeCommunityInput): Promise { + return this.xrpc.procedure(NSID.unsubscribe, input) + } + + blockCommunity(input: BlockCommunityInput): Promise { + return this.xrpc.procedure(NSID.blockCommunity, input) + } + + unblockCommunity(input: BlockCommunityInput): Promise { + return this.xrpc.procedure(NSID.unblockCommunity, input) + } + + // Posts + createPost(input: CreatePostInput): Promise { + return this.xrpc.procedure(NSID.createPost, input) + } + + deletePost(input: { uri: AtUri }): Promise { + return this.xrpc.procedure(NSID.deletePost, input) + } +} diff --git a/src/lib/api/coves/index.ts b/src/lib/api/coves/index.ts new file mode 100644 index 00000000..7ce209b8 --- /dev/null +++ b/src/lib/api/coves/index.ts @@ -0,0 +1,3 @@ +export { CovesClient } from './client' +export { XrpcError } from './xrpc' +export * from './types' diff --git a/src/lib/api/coves/types.test.ts b/src/lib/api/coves/types.test.ts new file mode 100644 index 00000000..3d0243db --- /dev/null +++ b/src/lib/api/coves/types.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from 'vitest' +import { + isValidAtUri, + asAtUri, + tryAsAtUri, + isValidCID, + asCID, + tryAsCID, +} from './types' + +// --------------------------------------------------------------------------- +// isValidAtUri +// --------------------------------------------------------------------------- + +describe('isValidAtUri', () => { + it('accepts a valid AT-URI with DID only', () => { + expect(isValidAtUri('at://did:plc:abc123')).toBe(true) + }) + + it('accepts a valid AT-URI with collection', () => { + expect(isValidAtUri('at://did:plc:abc123/app.bsky.feed.post')).toBe(true) + }) + + it('accepts a valid AT-URI with collection and rkey', () => { + expect(isValidAtUri('at://did:plc:abc123/app.bsky.feed.post/3jui7kd')).toBe( + true, + ) + }) + + it('accepts a DID with web method', () => { + expect(isValidAtUri('at://did:web:example.com')).toBe(true) + }) + + it('rejects an empty string', () => { + expect(isValidAtUri('')).toBe(false) + }) + + it('rejects a plain URL', () => { + expect(isValidAtUri('https://example.com')).toBe(false) + }) + + it('rejects a string without at:// prefix', () => { + expect(isValidAtUri('did:plc:abc123')).toBe(false) + }) + + it('rejects an AT-URI with missing DID method', () => { + expect(isValidAtUri('at://did:')).toBe(false) + }) + + it('rejects a random string', () => { + expect(isValidAtUri('hello world')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// asAtUri +// --------------------------------------------------------------------------- + +describe('asAtUri', () => { + it('returns the branded type for a valid AT-URI', () => { + const uri = asAtUri('at://did:plc:abc123/post/1') + expect(uri).toBe('at://did:plc:abc123/post/1') + }) + + it('throws on an invalid AT-URI', () => { + expect(() => asAtUri('not-an-at-uri')).toThrow('Invalid AT-URI format') + }) +}) + +// --------------------------------------------------------------------------- +// tryAsAtUri +// --------------------------------------------------------------------------- + +describe('tryAsAtUri', () => { + it('returns the branded type for a valid AT-URI', () => { + const uri = tryAsAtUri('at://did:plc:abc123/post/1') + expect(uri).toBe('at://did:plc:abc123/post/1') + }) + + it('returns null for an invalid AT-URI', () => { + expect(tryAsAtUri('not-valid')).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// isValidCID +// --------------------------------------------------------------------------- + +describe('isValidCID', () => { + it('accepts a valid base32 CID', () => { + expect(isValidCID('bafyreib2rxk3rybsftg4qpz')).toBe(true) + }) + + it('accepts a valid base64 CID', () => { + expect(isValidCID('QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG')).toBe( + true, + ) + }) + + it('rejects an empty string', () => { + expect(isValidCID('')).toBe(false) + }) + + it('rejects a string with spaces', () => { + expect(isValidCID('invalid cid')).toBe(false) + }) + + it('rejects a string with special characters', () => { + expect(isValidCID('bafy!@#$')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// asCID +// --------------------------------------------------------------------------- + +describe('asCID', () => { + it('returns the branded type for a valid CID', () => { + const cid = asCID('bafyreib2rxk3rybsftg4qpz') + expect(cid).toBe('bafyreib2rxk3rybsftg4qpz') + }) + + it('throws on an invalid CID', () => { + expect(() => asCID('')).toThrow('Invalid CID format') + }) +}) + +// --------------------------------------------------------------------------- +// tryAsCID +// --------------------------------------------------------------------------- + +describe('tryAsCID', () => { + it('returns the branded type for a valid CID', () => { + const cid = tryAsCID('bafyreib2rxk3rybsftg4qpz') + expect(cid).toBe('bafyreib2rxk3rybsftg4qpz') + }) + + it('returns null for an invalid CID', () => { + expect(tryAsCID('')).toBeNull() + }) + + it('returns null for a string with invalid characters', () => { + expect(tryAsCID('bafy!invalid')).toBeNull() + }) +}) diff --git a/src/lib/api/coves/types.ts b/src/lib/api/coves/types.ts index 65fb3ed0..6df8674b 100644 --- a/src/lib/api/coves/types.ts +++ b/src/lib/api/coves/types.ts @@ -1,4 +1,5 @@ -// Coves API data model types, derived from Go backend structs. +// Coves API data model types, derived from Go backend structs in: +// github.com/coves-social/coves/internal/core/{posts,communities,comments,users,votes,discover,timeline,communityFeeds} import type { DID, Handle } from '$lib/types/atproto' // --------------------------------------------------------------------------- @@ -25,6 +26,8 @@ export function tryAsAtUri(value: string): AtUri | null { } export function isValidCID(value: string): value is CID { + // Permissive check: CIDs can be multibase-encoded (base32, base58btc, base64, etc.) + // Full validation would require decoding; this just rejects obviously invalid strings. return value.length > 0 && /^[a-zA-Z0-9+/=]+$/.test(value) } @@ -37,6 +40,36 @@ export function tryAsCID(value: string): CID | null { return isValidCID(value) ? value : null } +// --------------------------------------------------------------------------- +// Record types — the actual atProto records stored in repositories +// --------------------------------------------------------------------------- + +export interface PostRecord { + $type: string + community: string + author: string + createdAt: string + title?: string + content?: string + embed?: Record + labels?: unknown + facets?: unknown[] + originalAuthor?: unknown + federatedFrom?: unknown + location?: unknown +} + +export interface CommentRecord { + $type: string + content: string + reply: { root: StrongRef; parent: StrongRef } + createdAt: string + facets?: unknown[] + langs?: string[] + embed?: unknown + labels?: unknown +} + // --------------------------------------------------------------------------- // Core view types — posts // --------------------------------------------------------------------------- @@ -83,7 +116,7 @@ export interface PostView { community: CommunityRef editedAt?: string language?: string - record?: unknown + record?: PostRecord embed?: PostEmbed viewer?: PostViewerState stats?: PostStats @@ -126,6 +159,8 @@ export interface PostRef { // Core view types — communities // --------------------------------------------------------------------------- +export type CommunityVisibility = 'public' | 'unlisted' | 'private' + export interface CommunityViewerState { subscribed?: boolean member?: boolean @@ -141,7 +176,7 @@ export interface CommunityView { displayName?: string displayHandle?: string avatar?: string - visibility?: string + visibility?: CommunityVisibility viewer?: CommunityViewerState } @@ -152,7 +187,7 @@ export interface CommunityViewDetailed extends CommunityView { banner?: string createdBy?: DID hostedBy?: DID - moderationType?: string + moderationType?: 'open' | 'restricted' | 'approval' contentWarnings?: string[] } @@ -182,7 +217,7 @@ export interface CommentView { cid: CID createdAt: string indexedAt: string - record: unknown + record: CommentRecord author: AuthorView post: CommentRef stats: CommentStats @@ -383,7 +418,7 @@ export interface GetCommunityParams { export interface ListCommunitiesParams { sort?: string - visibility?: string + visibility?: CommunityVisibility limit?: number offset?: number } @@ -394,7 +429,7 @@ export interface ListCommunitiesResponse { export interface SearchCommunitiesParams { query: string - visibility?: string + visibility?: CommunityVisibility limit?: number offset?: number } @@ -463,7 +498,7 @@ export interface CreateCommentOutput { export interface CreateCommunityInput { name: string description: string - visibility: string + visibility: CommunityVisibility displayName?: string language?: string allowExternalDiscovery?: boolean diff --git a/src/lib/api/coves/xrpc.test.ts b/src/lib/api/coves/xrpc.test.ts new file mode 100644 index 00000000..f5b040fc --- /dev/null +++ b/src/lib/api/coves/xrpc.test.ts @@ -0,0 +1,366 @@ +import { describe, it, expect, vi } from 'vitest' +import { XrpcClient, XrpcError } from './xrpc' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockFetch( + body: unknown, + init: { + status?: number + headers?: Record + } = {}, +): typeof fetch { + const { status = 200, headers = {} } = init + return vi.fn().mockResolvedValue( + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }), + ) as unknown as typeof fetch +} + +const BASE_URL = 'https://api.coves.social' + +// --------------------------------------------------------------------------- +// XrpcError +// --------------------------------------------------------------------------- + +describe('XrpcError', () => { + it('has correct name, status, errorName, and message properties', () => { + const err = new XrpcError(400, 'InvalidRequest', 'bad input') + + expect(err.name).toBe('XrpcError') + expect(err.status).toBe(400) + expect(err.errorName).toBe('InvalidRequest') + expect(err.message).toBe('bad input') + }) + + it('is instanceof Error', () => { + const err = new XrpcError(500, 'InternalError', 'boom') + + expect(err).toBeInstanceOf(Error) + }) +}) + +// --------------------------------------------------------------------------- +// XrpcClient.query() +// --------------------------------------------------------------------------- + +describe('XrpcClient.query()', () => { + let client: XrpcClient + + it('calls GET on correct URL with NSID', async () => { + const mockFetch = createMockFetch({ ok: true }) + client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) + + await client.query('social.coves.feed.getDiscover') + + expect(mockFetch).toHaveBeenCalledTimes(1) + const calledUrl = (mockFetch as ReturnType).mock + .calls[0][0] as string + expect(calledUrl).toBe(`${BASE_URL}/xrpc/social.coves.feed.getDiscover`) + }) + + it('serializes params as query string', async () => { + const mockFetch = createMockFetch({ items: [] }) + client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) + + await client.query('social.coves.feed.getDiscover', { + limit: 10, + sort: 'hot', + }) + + const calledUrl = (mockFetch as ReturnType).mock + .calls[0][0] as string + const url = new URL(calledUrl) + expect(url.searchParams.get('limit')).toBe('10') + expect(url.searchParams.get('sort')).toBe('hot') + }) + + it('skips undefined and null params', async () => { + const mockFetch = createMockFetch({ items: [] }) + client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) + + await client.query('social.coves.feed.getDiscover', { + limit: 10, + cursor: undefined, + sort: null, + }) + + const calledUrl = (mockFetch as ReturnType).mock + .calls[0][0] as string + const url = new URL(calledUrl) + expect(url.searchParams.get('limit')).toBe('10') + expect(url.searchParams.has('cursor')).toBe(false) + expect(url.searchParams.has('sort')).toBe(false) + }) + + it('returns parsed JSON response', async () => { + const expected = { feed: [{ id: 1 }], cursor: 'abc' } + const mockFetch = createMockFetch(expected) + client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) + + const result = await client.query('social.coves.feed.getDiscover') + + expect(result).toEqual(expected) + }) + + it('throws XrpcError with status, errorName, message on non-ok response', async () => { + const errorBody = { + error: 'InvalidRequest', + message: 'Missing required param', + } + const mockFetch = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify(errorBody), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) as unknown as typeof fetch + client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) + + try { + await client.query('social.coves.feed.getDiscover') + expect.fail('Expected XrpcError to be thrown') + } catch (e) { + expect(e).toBeInstanceOf(XrpcError) + const err = e as XrpcError + expect(err.status).toBe(400) + expect(err.errorName).toBe('InvalidRequest') + expect(err.message).toBe('Missing required param') + } + }) + + it('throws XrpcError with generic message when error body is not valid JSON', async () => { + const mockFetch = vi + .fn() + .mockImplementation(() => + Promise.resolve(new Response('not json', { status: 502 })), + ) as unknown as typeof fetch + client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) + + try { + await client.query('social.coves.feed.getDiscover') + expect.fail('Expected XrpcError to be thrown') + } catch (e) { + expect(e).toBeInstanceOf(XrpcError) + const err = e as XrpcError + expect(err.status).toBe(502) + expect(err.errorName).toBe('UnknownError') + expect(err.message).toBe('XRPC request failed with status 502') + } + }) + + it('throws XrpcError with ParseError when success response is not valid JSON', async () => { + const mockFetch = vi + .fn() + .mockImplementation(() => + Promise.resolve(new Response('not json', { status: 200 })), + ) as unknown as typeof fetch + client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) + + try { + await client.query('social.coves.feed.getDiscover') + expect.fail('Expected XrpcError to be thrown') + } catch (e) { + expect(e).toBeInstanceOf(XrpcError) + const err = e as XrpcError + expect(err.status).toBe(200) + expect(err.errorName).toBe('ParseError') + expect(err.message).toBe('Failed to parse response as JSON') + } + }) + + it('propagates fetch errors as-is (not wrapped in XrpcError)', async () => { + const mockFetch = vi + .fn() + .mockRejectedValue( + new TypeError('Failed to fetch'), + ) as unknown as typeof fetch + client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) + + await expect(client.query('social.coves.feed.getDiscover')).rejects.toThrow( + TypeError, + ) + }) + + it('falls back to UnknownError when error body JSON lacks error/message fields', async () => { + const mockFetch = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ foo: 'bar' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) as unknown as typeof fetch + client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) + + try { + await client.query('social.coves.feed.getDiscover') + expect.fail('Expected XrpcError to be thrown') + } catch (e) { + expect(e).toBeInstanceOf(XrpcError) + const err = e as XrpcError + expect(err.status).toBe(400) + expect(err.errorName).toBe('UnknownError') + expect(err.message).toBe('XRPC request failed with status 400') + } + }) +}) + +// --------------------------------------------------------------------------- +// XrpcClient.procedure() +// --------------------------------------------------------------------------- + +describe('XrpcClient.procedure()', () => { + let client: XrpcClient + + it('sends undefined body when no input is provided', async () => { + const mockFetch = createMockFetch({}) + client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) + + await client.procedure('social.coves.community.subscribe') + + const calledInit = (mockFetch as ReturnType).mock + .calls[0][1] as RequestInit + expect(calledInit.body).toBeUndefined() + }) + + it('calls POST on correct URL with NSID', async () => { + const mockFetch = createMockFetch({ uri: 'at://did:plc:abc/post/1' }) + client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) + + await client.procedure('social.coves.community.post.create', { + title: 'hi', + }) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const [calledUrl, calledInit] = (mockFetch as ReturnType).mock + .calls[0] as [string, RequestInit] + expect(calledUrl).toBe( + `${BASE_URL}/xrpc/social.coves.community.post.create`, + ) + expect(calledInit.method).toBe('POST') + }) + + it('sends JSON body with Content-Type header', async () => { + const mockFetch = createMockFetch({ uri: 'at://did:plc:abc/post/1' }) + client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) + + const input = { title: 'Hello', content: 'World' } + await client.procedure('social.coves.community.post.create', input) + + const calledInit = (mockFetch as ReturnType).mock + .calls[0][1] as RequestInit + expect(calledInit.headers).toEqual({ 'Content-Type': 'application/json' }) + expect(calledInit.body).toBe(JSON.stringify(input)) + }) + + it('returns parsed JSON response', async () => { + const expected = { uri: 'at://did:plc:abc/post/1', cid: 'bafy123' } + const mockFetch = createMockFetch(expected) + client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) + + const result = await client.procedure( + 'social.coves.community.post.create', + { title: 'hi' }, + ) + + expect(result).toEqual(expected) + }) + + it('returns undefined for 204 responses', async () => { + const mockFetch = vi + .fn() + .mockResolvedValue( + new Response(null, { status: 204 }), + ) as unknown as typeof fetch + client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) + + const result = await client.procedure( + 'social.coves.community.post.delete', + { uri: 'at://x' }, + ) + + expect(result).toBeUndefined() + }) + + it('returns undefined for empty body responses', async () => { + const mockFetch = vi + .fn() + .mockResolvedValue( + new Response('', { status: 200 }), + ) as unknown as typeof fetch + client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) + + const result = await client.procedure('social.coves.community.subscribe', { + community: 'c', + }) + + expect(result).toBeUndefined() + }) + + it('throws XrpcError on non-ok response', async () => { + const errorBody = { error: 'Forbidden', message: 'Not allowed' } + const mockFetch = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify(errorBody), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) as unknown as typeof fetch + client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) + + try { + await client.procedure('social.coves.community.post.create', { + title: 'hi', + }) + expect.fail('Expected XrpcError to be thrown') + } catch (e) { + expect(e).toBeInstanceOf(XrpcError) + const err = e as XrpcError + expect(err.status).toBe(403) + expect(err.errorName).toBe('Forbidden') + expect(err.message).toBe('Not allowed') + } + }) + + it('propagates fetch errors as-is (not wrapped in XrpcError)', async () => { + const mockFetch = vi + .fn() + .mockRejectedValue( + new TypeError('Failed to fetch'), + ) as unknown as typeof fetch + client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) + + await expect( + client.procedure('social.coves.community.post.create', { title: 'hi' }), + ).rejects.toThrow(TypeError) + }) + + it('throws XrpcError with ParseError when success body is not valid JSON', async () => { + const mockFetch = vi + .fn() + .mockImplementation(() => + Promise.resolve(new Response('not json at all', { status: 200 })), + ) as unknown as typeof fetch + client = new XrpcClient({ fetchFn: mockFetch, baseUrl: BASE_URL }) + + try { + await client.procedure('social.coves.community.post.create', { + title: 'hi', + }) + expect.fail('Expected XrpcError to be thrown') + } catch (e) { + expect(e).toBeInstanceOf(XrpcError) + const err = e as XrpcError + expect(err.status).toBe(200) + expect(err.errorName).toBe('ParseError') + expect(err.message).toBe('Failed to parse response as JSON') + } + }) +}) diff --git a/src/lib/api/coves/xrpc.ts b/src/lib/api/coves/xrpc.ts new file mode 100644 index 00000000..5eaee70c --- /dev/null +++ b/src/lib/api/coves/xrpc.ts @@ -0,0 +1,110 @@ +// XRPC transport layer for ATProto lexicon calls. + +export class XrpcError extends Error { + constructor( + public status: number, + public errorName: string, + message: string, + ) { + super(message) + this.name = 'XrpcError' + } +} + +interface XrpcClientOptions { + fetchFn: typeof fetch + baseUrl: string +} + +export class XrpcClient { + readonly #fetchFn: typeof fetch + readonly #baseUrl: string + + constructor(options: XrpcClientOptions) { + this.#fetchFn = options.fetchFn + this.#baseUrl = options.baseUrl + } + + async query(nsid: string, params?: P): Promise { + const url = new URL(`/xrpc/${nsid}`, this.#baseUrl) + + if (params) { + const searchParams = new URLSearchParams() + for (const [key, value] of Object.entries( + params as Record, + )) { + if (value === undefined || value === null) continue + searchParams.set(key, String(value)) + } + url.search = searchParams.toString() + } + + const res = await this.#fetchFn(url.toString()) + + if (!res.ok) { + throw await this.#parseError(res) + } + + try { + return (await res.json()) as R + } catch { + throw new XrpcError( + res.status, + 'ParseError', + 'Failed to parse response as JSON', + ) + } + } + + async procedure(nsid: string, input?: I): Promise { + const url = new URL(`/xrpc/${nsid}`, this.#baseUrl) + + const res = await this.#fetchFn(url.toString(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: input !== undefined ? JSON.stringify(input) : undefined, + }) + + if (!res.ok) { + throw await this.#parseError(res) + } + + if (res.status === 204) { + return undefined as O + } + + const text = await res.text() + if (!text) { + return undefined as O + } + + try { + return JSON.parse(text) as O + } catch { + throw new XrpcError( + res.status, + 'ParseError', + 'Failed to parse response as JSON', + ) + } + } + + async #parseError(res: Response): Promise { + try { + const body = (await res.json()) as Record + const errorName = + typeof body.error === 'string' ? body.error : 'UnknownError' + const message = + typeof body.message === 'string' + ? body.message + : `XRPC request failed with status ${res.status}` + return new XrpcError(res.status, errorName, message) + } catch { + return new XrpcError( + res.status, + 'UnknownError', + `XRPC request failed with status ${res.status}`, + ) + } + } +} -- 2.51.2