social bookmarking for atproto

[appview/xrpc] Implement social.clippr.feed.getClips

hexmani.ac 35f6c8c1 ef5f8721

verified
+1628 -1310
+123
backend/src/api/feed.ts
··· 1 + /* 2 + * clippr: a social bookmarking service for the AT Protocol 3 + * Copyright (c) 2025 clippr contributors. 4 + * SPDX-License-Identifier: AGPL-3.0-only 5 + */ 6 + 7 + import { 8 + SocialClipprActorDefs, 9 + SocialClipprFeedClip, 10 + SocialClipprFeedDefs, 11 + } from "@clipprjs/lexicons"; 12 + import type { ClipViewQuery, ErrorResponse, TagRef } from "./types.js"; 13 + import { getHandleFromDid } from "../network/converters.js"; 14 + import { Database } from "../db/database.js"; 15 + import { clipsTable } from "../db/schema.js"; 16 + import { and, eq } from "drizzle-orm"; 17 + import { createProfileView } from "./profile.js"; 18 + import { is } from "@atcute/lexicons"; 19 + import { validateHash } from "../hasher.js"; 20 + 21 + const db = Database.getInstance().getDb(); 22 + 23 + export async function createClipView( 24 + query: ClipViewQuery, 25 + ): Promise<SocialClipprFeedDefs.ClipView | ErrorResponse> { 26 + if (!query.did.startsWith("did:")) { 27 + let did; 28 + try { 29 + did = await getHandleFromDid(query.did); 30 + } catch (e: unknown) { 31 + if (e instanceof Error) { 32 + return { 33 + error: "InvalidRequest", 34 + message: `Error: A queried URI does not have a valid DID or handle: ${e.message}`, 35 + }; 36 + } else { 37 + return { 38 + error: "InvalidRequest", 39 + message: 40 + "Error: A queried URI does not have a valid DID or handle: unknown error", 41 + }; 42 + } 43 + } 44 + query.did = did; 45 + } 46 + 47 + if (query.collection !== "social.clippr.feed.clip") { 48 + return { 49 + error: "InvalidRequest", 50 + message: "Error: A queried URI is not a proper clip", 51 + }; 52 + } 53 + 54 + const dbQuery = await db 55 + .selectDistinct() 56 + .from(clipsTable) 57 + .where( 58 + and( 59 + eq(clipsTable.did, query.did), 60 + eq(clipsTable.recordKey, query.recordKey), 61 + ), 62 + ); 63 + 64 + if (dbQuery.length === 0) { 65 + return { 66 + error: "InvalidRequest", 67 + message: "Could not find a given URI", 68 + }; 69 + } 70 + 71 + // Yes, the array thing is not ideal. 72 + if (!dbQuery[0]?.cid) { 73 + return { 74 + error: "InvalidRequest", 75 + message: "Could not find a given URI", 76 + }; 77 + } 78 + 79 + if (!(await validateHash(dbQuery[0]?.url, query.recordKey))) { 80 + return { 81 + error: "InvalidRequest", 82 + message: "Could not find a given URI", 83 + }; 84 + } 85 + 86 + const authorView: ErrorResponse | SocialClipprActorDefs.ProfileView = 87 + await createProfileView(query.did); 88 + 89 + if (!is(SocialClipprActorDefs.profileViewSchema, authorView)) { 90 + console.log(authorView); 91 + return { 92 + error: "InvalidRequest", 93 + message: "Could not validate profile view", // I can't get the error message, it seems to always assume the type is the ProfileView 94 + } as ErrorResponse; 95 + } 96 + 97 + let clipTags: TagRef[] | undefined; 98 + 99 + if (dbQuery[0]?.tags === null) { 100 + clipTags = undefined; 101 + } 102 + 103 + const clipRecord: SocialClipprFeedClip.Main = { 104 + $type: "social.clippr.feed.clip", 105 + url: dbQuery[0]?.url as `${string}:${string}`, 106 + title: dbQuery[0]?.title, 107 + description: dbQuery[0]?.description, 108 + tags: clipTags || undefined, 109 + unlisted: dbQuery[0]?.unlisted, 110 + unread: dbQuery[0]?.unread || undefined, 111 + notes: dbQuery[0]?.notes || undefined, 112 + languages: dbQuery[0]?.languages || undefined, 113 + createdAt: dbQuery[0]?.createdAt.toISOString(), 114 + }; 115 + 116 + return { 117 + cid: dbQuery[0]?.cid, 118 + uri: `at://${query.did}/${query.collection}/${query.recordKey}`, 119 + author: authorView, 120 + record: clipRecord, 121 + indexedAt: dbQuery[0]?.indexedAt.toISOString(), 122 + }; 123 + }
+101
backend/src/api/profile.ts
··· 1 + /* 2 + * clippr: a social bookmarking service for the AT Protocol 3 + * Copyright (c) 2025 clippr contributors. 4 + * SPDX-License-Identifier: AGPL-3.0-only 5 + */ 6 + 7 + import { SocialClipprActorDefs } from "@clipprjs/lexicons"; 8 + import type { ErrorResponse } from "./types.js"; 9 + import { getDidFromHandle, getHandleFromDid } from "../network/converters.js"; 10 + import { Database } from "../db/database.js"; 11 + import { usersTable } from "../db/schema.js"; 12 + import { eq } from "drizzle-orm"; 13 + 14 + const db = Database.getInstance().getDb(); 15 + 16 + // TODO: Stop leeching off the Bluesky CDN and get the blob directly from the user's PDS 17 + // 18 + // Get a CDN URI from a blob's CID 19 + export async function createAvatarLink( 20 + did: string, 21 + cid: string, 22 + ): Promise<string> { 23 + return `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}`; 24 + } 25 + 26 + export async function createProfileView( 27 + actor: string, 28 + ): Promise<SocialClipprActorDefs.ProfileView | ErrorResponse> { 29 + let viewHandle; 30 + let viewDid; 31 + 32 + if (actor.startsWith("did:")) { 33 + viewDid = actor; 34 + try { 35 + viewHandle = await getHandleFromDid(viewDid); 36 + viewHandle = viewHandle.replace("at://", ""); 37 + } catch (e: unknown) { 38 + if (e instanceof Error) { 39 + return { 40 + error: "InvalidRequest", 41 + message: `Error: A queried URI does not have a valid DID or handle: ${e.message}`, 42 + }; 43 + } else { 44 + return { 45 + error: "InvalidRequest", 46 + message: 47 + "Error: A queried URI does not have a valid DID or handle: unknown error", 48 + }; 49 + } 50 + } 51 + } else { 52 + viewHandle = actor; 53 + viewHandle = viewHandle.replace("at://", ""); 54 + try { 55 + viewDid = await getDidFromHandle(viewHandle); 56 + } catch (e: unknown) { 57 + if (e instanceof Error) { 58 + return { 59 + error: "InvalidRequest", 60 + message: `Error: A queried URI does not have a valid DID or handle: ${e.message}`, 61 + }; 62 + } else { 63 + return { 64 + error: "InvalidRequest", 65 + message: 66 + "Error: A queried URI does not have a valid DID or handle: unknown error", 67 + }; 68 + } 69 + } 70 + } 71 + 72 + const dbQuery = await db 73 + .selectDistinct() 74 + .from(usersTable) 75 + .where(eq(usersTable.did, viewDid)); 76 + 77 + if (dbQuery.length === 0) { 78 + return { 79 + error: "InvalidRequest", 80 + message: "Could not find a queried URI's profile", 81 + } as ErrorResponse; 82 + } 83 + 84 + const avatarCid = dbQuery[0]?.avatar; 85 + 86 + let viewAvatar; 87 + 88 + if (avatarCid === undefined || avatarCid === null) { 89 + viewAvatar = "https://missing.avatar"; 90 + } else viewAvatar = await createAvatarLink(viewDid, avatarCid); 91 + 92 + return { 93 + $type: "social.clippr.actor.defs#profileView", 94 + did: viewDid as `did:${string}:${string}`, 95 + handle: viewHandle as `${string}.${string}`, 96 + avatar: (viewAvatar as `${string}:${string}`) || undefined, 97 + createdAt: dbQuery[0]?.createdAt.toISOString(), 98 + description: dbQuery[0]?.description || undefined, 99 + displayName: dbQuery[0]?.displayName || viewHandle, 100 + }; 101 + }
+38
backend/src/api/types.ts
··· 1 + /* 2 + * clippr: a social bookmarking service for the AT Protocol 3 + * Copyright (c) 2025 clippr contributors. 4 + * SPDX-License-Identifier: AGPL-3.0-only 5 + */ 6 + 7 + import type { ResourceUri } from "@atcute/lexicons"; 8 + 9 + export interface ErrorResponse { 10 + error: string; 11 + message: string; 12 + } 13 + 14 + export interface ProfileViewQuery { 15 + actor: string; 16 + } 17 + 18 + export interface ClipViewQuery { 19 + did: string; 20 + collection: string; 21 + recordKey: string; 22 + } 23 + 24 + export const isClipViewQuery = (query: unknown): query is ClipViewQuery => { 25 + return typeof query === "object" && query !== null && "did" in query; 26 + }; 27 + 28 + export interface TagRef { 29 + $type: "com.atproto.repo.strongRef"; 30 + cid: string; 31 + uri: ResourceUri; 32 + } 33 + 34 + export interface TagViewQuery { 35 + did: string; 36 + collection: string; 37 + recordKey: string; 38 + }
+3 -2
backend/src/db/schema.ts
··· 6 6 7 7 import { int, sqliteTable, text } from "drizzle-orm/sqlite-core"; 8 8 import { sql } from "drizzle-orm"; 9 + import type { TagRef } from "../api/types.js"; 9 10 10 11 // WebStorm keeps throwing errors with the default statements as it wants 11 12 // an actual SQLite query, despite being valid. Sucks. ··· 23 24 unlisted: int("unlisted", { mode: "boolean" }).notNull(), 24 25 notes: text("notes"), 25 26 tags: text("tags", { mode: "json" }) 26 - .$type<string[]>() 27 + .$type<TagRef[]>() 27 28 .default(sql`'[]'`), 28 29 unread: int("unread", { mode: "boolean" }), 29 30 languages: text("languages", { mode: "json" }) ··· 63 64 .default(sql`(unixepoch() * 1000)`), 64 65 did: text("did").notNull().unique(), 65 66 cid: text("cid").notNull(), 66 - displayName: text("displayName"), 67 + displayName: text("displayName").notNull(), 67 68 description: text("description"), 68 69 avatar: text("avatar"), 69 70 createdAt: int("createdAt", { mode: "timestamp_ms" })
+21
backend/src/hasher.ts
··· 1 + /* 2 + * clippr: a social bookmarking service for the AT Protocol 3 + * Copyright (c) 2025 clippr contributors. 4 + * SPDX-License-Identifier: AGPL-3.0-only 5 + */ 6 + 7 + import xxhash from "xxhash-wasm"; 8 + 9 + /// Hash a given string into a hexadecimal xxh64 string. 10 + export async function hashString(data: string): Promise<string> { 11 + const { h64 } = await xxhash(); 12 + return h64(data).toString(16); 13 + } 14 + 15 + /// Check if a string is equivalent to a given hash. 16 + export async function validateHash( 17 + data: string, 18 + hash: string, 19 + ): Promise<boolean> { 20 + return hash === (await hashString(data)); 21 + }
+2 -3
backend/src/network/commit.ts
··· 16 16 import Logger from "../logger.js"; 17 17 import { isBlob } from "@atcute/lexicons/interfaces"; 18 18 import { validateClip, validateProfile, validateTag } from "./validator.js"; 19 - import xxhash from "xxhash-wasm"; 20 19 import { convertDidToString } from "./converters.js"; 20 + import { hashString } from "../hasher.js"; 21 21 22 22 const db = Database.getInstance().getDb(); 23 23 ··· 65 65 }; 66 66 67 67 // xxh64, NOT xxh3 learned that the hard way 68 - const { h64 } = await xxhash(); 69 - const urlHash = h64(record.url).toString(16); 68 + const urlHash: string = await hashString(record.url); 70 69 71 70 if (urlHash !== event.commit.rkey) { 72 71 Logger.verbose(
-9
backend/src/network/converters.ts
··· 31 31 } 32 32 } 33 33 34 - // TODO: Stop leeching off the Bluesky CDN and get the blob directly from the user's PDS 35 - // Get a CDN URI from a blob's CID 36 - export async function getUriFromBlobCid( 37 - did: string, 38 - cid: string, 39 - ): Promise<string> { 40 - return `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}`; 41 - } 42 - 43 34 // Get a user's handle from their DID. DID method agnostic. 44 35 export async function getHandleFromDid(did: string): Promise<string> { 45 36 const docResolver = new CompositeDidDocumentResolver({
+114 -6
backend/src/routes/xrpc.ts
··· 8 8 import { Database } from "../db/database.js"; 9 9 import { usersTable } from "../db/schema.js"; 10 10 import { eq } from "drizzle-orm"; 11 - import { 12 - getDidFromHandle, 13 - getHandleFromDid, 14 - getUriFromBlobCid, 15 - } from "../network/converters.js"; 11 + import { getDidFromHandle, getHandleFromDid } from "../network/converters.js"; 12 + import { createClipView } from "../api/feed.js"; 13 + import { type ClipViewQuery, type ErrorResponse } from "../api/types.js"; 14 + import { createAvatarLink } from "../api/profile.js"; 15 + import { SocialClipprFeedDefs } from "@clipprjs/lexicons"; 16 + import { is } from "@atcute/lexicons"; 16 17 17 18 const app = new Hono(); 18 19 const db = Database.getInstance().getDb(); ··· 101 102 } else actorHandle = actor; 102 103 103 104 // TODO: Add placeholder avatar 105 + // This is a mess and should be replaced with a real solution! 104 106 const avatarCid: string = 105 107 profileSearch[0]?.avatar || "https://missing.avatar"; 106 108 let actorAvatar; 107 109 if (avatarCid !== "https://missing.avatar") { 108 - actorAvatar = await getUriFromBlobCid(actorDid, avatarCid); 110 + actorAvatar = await createAvatarLink(actorDid, avatarCid); 109 111 } else actorAvatar = avatarCid; 110 112 111 113 // Right now we don't do de-duplication in the database, so we just take the ··· 118 120 description: profileSearch[0]?.description || null, 119 121 createdAt: profileSearch[0]?.createdAt, 120 122 }); 123 + }); 124 + 125 + app.get("/social.clippr.feed.getClips", async (c) => { 126 + const uris = c.req.query("uris"); 127 + if (uris === undefined || uris.trim().length === 0) { 128 + return c.json( 129 + { 130 + error: "InvalidRequest", 131 + message: "Error: Parameters must have the uris property included", 132 + }, 133 + 400, 134 + ); 135 + } 136 + 137 + const rawUriArray: string[] = uris.split(","); 138 + 139 + if (rawUriArray.length > 25) { 140 + return c.json( 141 + { 142 + error: "InvalidRequest", 143 + message: "Error: More than 25 URIs have been provided", 144 + }, 145 + 400, 146 + ); 147 + } 148 + 149 + if ( 150 + rawUriArray.some((value) => { 151 + return !value.startsWith("at://"); 152 + }) 153 + ) { 154 + return c.json( 155 + { 156 + error: "InvalidRequest", 157 + message: "Error: A queried URI is missing the at:// identifier", 158 + }, 159 + 400, 160 + ); 161 + } 162 + 163 + const parsedUriArray: object[] = []; 164 + 165 + for (let value of rawUriArray) { 166 + value = value.replace("at://", ""); 167 + const splitUri: string[] = value.split("/"); 168 + 169 + if (splitUri.length !== 3) { 170 + c.json( 171 + { 172 + error: "InvalidRequest", 173 + message: "Error: A queried URI is not a proper clip", 174 + }, 175 + 400, 176 + ); 177 + } 178 + 179 + let splitUriObject: ClipViewQuery = { 180 + did: "", 181 + collection: "", 182 + recordKey: "", 183 + }; 184 + 185 + // validate type 186 + if ( 187 + !splitUri[0] || 188 + !splitUri[1] || 189 + !splitUri[2] || 190 + typeof splitUri[0] !== "string" || 191 + typeof splitUri[1] !== "string" || 192 + typeof splitUri[2] !== "string" 193 + ) { 194 + c.json( 195 + { 196 + error: "InvalidRequest", 197 + message: "Error: A queried URI is not a proper clip", 198 + }, 199 + 400, 200 + ); 201 + } else { 202 + splitUriObject = { 203 + did: splitUri[0], 204 + collection: splitUri[1], 205 + recordKey: splitUri[2], 206 + }; 207 + } 208 + 209 + const clipView = await createClipView(splitUriObject); 210 + 211 + if (!is(SocialClipprFeedDefs.clipViewSchema, value)) { 212 + c.json(clipView, 400); 213 + } 214 + 215 + parsedUriArray.push(clipView); 216 + } 217 + 218 + if (parsedUriArray.length === 0) { 219 + return c.json( 220 + { 221 + error: "InvalidRequest", 222 + message: "No queried URIs exist", 223 + } as ErrorResponse, 224 + 400, 225 + ); 226 + } 227 + 228 + return c.json(parsedUriArray, 200); 121 229 }); 122 230 123 231 app.get("/_health", async (c) => {
+1226 -1290
backend/static/api.json
··· 1 1 { 2 - "openapi": "3.1.1", 3 - "info": { 4 - "title": "Clippr AppView API", 5 - "version": "1.0.1", 6 - "description": "API reference documentation for Clippr's backend.", 7 - "license": { 8 - "name": "GNU Affero General Public License v3.0 only", 9 - "identifier": "AGPL-3.0-only" 10 - } 11 - }, 12 - "servers": [ 13 - { 14 - "url": "http://localhost:9090", 15 - "description": "Development server" 16 - }, 17 - { 18 - "url": "https://api.clippr.social", 19 - "description": "Production server" 20 - } 21 - ], 22 - "tags": [ 23 - { 24 - "name": "Clips", 25 - "description": "API paths that relate to user bookmarks, or 'clips'." 26 - }, 27 - { 28 - "name": "Tags", 29 - "description": "API paths that relate to user tags." 30 - }, 31 - { 32 - "name": "Profile", 33 - "description": "API paths that relate to user profiles." 34 - }, 35 - { 36 - "name": "Misc", 37 - "description": "API paths that don't fit into any other category." 38 - } 39 - ], 40 - "paths": { 41 - "/xrpc/social.clippr.actor.getPreferences": { 42 - "get": { 43 - "tags": [ 44 - "Profile" 45 - ], 46 - "summary": "Get a user's preferences", 47 - "operationId": "social.clippr.actor.getPreferences", 48 - "description": "Get a user's private preferences. Requires authentication.", 49 - "security": [ 50 - { 51 - "Bearer": [] 52 - } 53 - ], 54 - "responses": { 55 - "200": { 56 - "description": "OK", 57 - "content": { 58 - "application/json": { 59 - "schema": { 60 - "$ref": "#/components/schemas/social.clippr.actor.defs.preferences" 61 - } 62 - } 63 - } 64 - }, 65 - "400": { 66 - "description": "Bad Request", 67 - "content": { 68 - "application/json": { 69 - "schema": { 70 - "type": "object", 71 - "properties": { 72 - "error": { 73 - "type": "string", 74 - "description": "A general error code", 75 - "oneOf": [ 76 - { 77 - "const": "InvalidRequest" 78 - }, 79 - { 80 - "const": "ExpiredToken" 81 - }, 82 - { 83 - "const": "InvalidToken" 84 - } 85 - ] 86 - }, 87 - "message": { 88 - "type": "string", 89 - "description": "A detailed description of the error" 90 - } 91 - } 92 - } 93 - } 94 - } 95 - }, 96 - "401": { 97 - "description": "Unauthorized", 98 - "content": { 99 - "application/json": { 100 - "schema": { 101 - "type": "object", 102 - "properties": { 103 - "error": { 104 - "type": "string", 105 - "description": "A general error code", 106 - "oneOf": [ 107 - { 108 - "const": "AuthMissing" 109 - } 110 - ] 111 - }, 112 - "message": { 113 - "type": "string", 114 - "description": "A detailed description of the error" 115 - } 116 - } 117 - } 118 - } 119 - } 120 - } 121 - } 122 - } 123 - }, 124 - "/xrpc/social.clippr.actor.getProfile": { 125 - "get": { 126 - "tags": [ 127 - "Profile" 128 - ], 129 - "summary": "Get a profile", 130 - "operationId": "social.clippr.actor.getProfile", 131 - "description": "Get a user's profile based on a given DID or handle.", 132 - "parameters": [ 133 - { 134 - "name": "actor", 135 - "in": "query", 136 - "description": "Handle or DID of account to fetch profile of", 137 - "required": true, 138 - "content": { 139 - "schema": { 140 - "type": "string", 141 - "description": "Handle or DID of account to fetch profile of", 142 - "format": "at-identifier" 143 - } 144 - }, 145 - "deprecated": false, 146 - "allowEmptyValue": false 147 - } 148 - ], 149 - "responses": { 150 - "200": { 151 - "description": "OK", 152 - "content": { 153 - "application/json": { 154 - "schema": { 155 - "type": "object", 156 - "$ref": "#/components/schemas/social.clippr.actor.defs.profileView" 157 - } 158 - } 159 - } 160 - }, 161 - "400": { 162 - "description": "Bad Request", 163 - "content": { 164 - "application/json": { 165 - "schema": { 166 - "type": "object", 167 - "properties": { 168 - "error": { 169 - "type": "string", 170 - "description": "A general error code", 171 - "oneOf": [ 172 - { 173 - "const": "InvalidRequest" 174 - } 175 - ] 176 - }, 177 - "message": { 178 - "type": "string", 179 - "description": "A detailed description of the error" 180 - } 181 - } 182 - } 183 - } 184 - } 185 - } 186 - } 187 - } 188 - }, 189 - "/xrpc/social.clippr.actor.putPreferences": { 190 - "post": { 191 - "tags": [ 192 - "Profile" 193 - ], 194 - "summary": "Set a user's preferences", 195 - "operationId": "social.clippr.actor.putPreferences", 196 - "description": "Sets the private preferences attached to the account. Requires authentication.", 197 - "security": [ 198 - { 199 - "Bearer": [] 200 - } 201 - ], 202 - "requestBody": { 203 - "required": true, 204 - "content": { 205 - "application/json": { 206 - "schema": { 207 - "type": "object", 208 - "properties": { 209 - "preferences": { 210 - "$ref": "#/components/schemas/social.clippr.actor.defs.preferences" 211 - } 212 - } 213 - } 214 - } 215 - } 216 - }, 217 - "responses": { 218 - "200": { 219 - "description": "OK" 220 - }, 221 - "400": { 222 - "description": "Bad Request", 223 - "content": { 224 - "application/json": { 225 - "schema": { 226 - "type": "object", 227 - "properties": { 228 - "error": { 229 - "type": "string", 230 - "oneOf": [ 231 - { 232 - "const": "InvalidRequest" 233 - }, 234 - { 235 - "const": "ExpiredToken" 236 - }, 237 - { 238 - "const": "InvalidToken" 239 - } 240 - ], 241 - "description": "A general error code" 242 - }, 243 - "message": { 244 - "type": "string", 245 - "description": "A detailed description of the error" 246 - } 247 - } 248 - } 249 - } 250 - } 251 - }, 252 - "401": { 253 - "description": "Unauthorized", 254 - "content": { 255 - "application/json": { 256 - "schema": { 257 - "type": "object", 258 - "properties": { 259 - "error": { 260 - "type": "string", 261 - "description": "A general error code", 262 - "oneOf": [ 263 - { 264 - "const": "AuthMissing" 265 - } 266 - ] 267 - }, 268 - "message": { 269 - "type": "string", 270 - "description": "A detailed description of the error" 271 - } 272 - } 273 - } 274 - } 275 - } 276 - } 277 - } 278 - } 279 - }, 280 - "/xrpc/social.clippr.actor.searchClips": { 281 - "get": { 282 - "tags": [ 283 - "Clips" 284 - ], 285 - "summary": "Search clips", 286 - "operationId": "social.clippr.actor.searchClips", 287 - "description": "Find clips matching search criteria.", 288 - "parameters": [ 289 - { 290 - "name": "q", 291 - "in": "query", 292 - "description": "Search query string", 293 - "required": true, 294 - "schema": { 295 - "type": "string", 296 - "description": "Search query string" 297 - } 298 - }, 299 - { 300 - "name": "limit", 301 - "in": "query", 302 - "description": "How many clips to return in the query output", 303 - "required": false, 304 - "schema": { 305 - "type": "integer", 306 - "minimum": 1, 307 - "maximum": 100, 308 - "default": 25 309 - } 310 - }, 311 - { 312 - "name": "actor", 313 - "in": "query", 314 - "description": "An actor to filter results to", 315 - "required": false, 316 - "schema": { 317 - "type": "string", 318 - "description": "An actor to filter results to", 319 - "format": "at-identifier" 320 - } 321 - }, 322 - { 323 - "name": "cursor", 324 - "in": "query", 325 - "description": "A parameter to paginate results", 326 - "required": false, 327 - "schema": { 328 - "type": "string", 329 - "description": "A parameter to paginate results" 330 - } 331 - } 332 - ], 333 - "responses": { 334 - "200": { 335 - "description": "OK", 336 - "content": { 337 - "application/json": { 338 - "schema": { 339 - "type": "object", 340 - "properties": { 341 - "cursor": { 342 - "type": "string", 343 - "description": "A parameter to paginate results" 344 - }, 345 - "clips": { 346 - "type": "array", 347 - "items": { 348 - "$ref": "#/components/schemas/social.clippr.feed.defs.clipView" 349 - } 350 - } 351 - } 352 - } 353 - } 354 - } 355 - }, 356 - "400": { 357 - "description": "Bad Request", 358 - "content": { 359 - "application/json": { 360 - "schema": { 361 - "type": "object", 362 - "properties": { 363 - "error": { 364 - "type": "string", 365 - "description": "A general error code", 366 - "oneOf": [ 367 - { 368 - "const": "InvalidRequest" 369 - } 370 - ] 371 - }, 372 - "message": { 373 - "type": "string", 374 - "description": "A detailed description of the error" 375 - } 376 - } 377 - } 378 - } 379 - } 380 - } 381 - } 382 - } 383 - }, 384 - "/xrpc/social.clippr.actor.searchProfiles": { 385 - "get": { 386 - "tags": [ 387 - "Profile" 388 - ], 389 - "summary": "Search profiles", 390 - "operationId": "social.clippr.actor.searchProfiles", 391 - "description": "Find profiles matching search criteria.", 392 - "parameters": [ 393 - { 394 - "name": "q", 395 - "in": "query", 396 - "description": "Search query string", 397 - "required": false, 398 - "schema": { 399 - "type": "string", 400 - "description": "Search query string" 401 - } 402 - }, 403 - { 404 - "name": "limit", 405 - "in": "query", 406 - "description": "The number of profiles to be returned in the query", 407 - "required": false, 408 - "schema": { 409 - "type": "integer", 410 - "minimum": 1, 411 - "maximum": 100, 412 - "default": 25 413 - } 414 - }, 415 - { 416 - "name": "cursor", 417 - "in": "query", 418 - "description": "A parameter used for pagination", 419 - "required": false, 420 - "schema": { 421 - "type": "string", 422 - "description": "A parameter used for pagination" 423 - } 424 - } 425 - ], 426 - "responses": { 427 - "200": { 428 - "description": "OK", 429 - "content": { 430 - "application/json": { 431 - "schema": { 432 - "type": "object", 433 - "properties": { 434 - "cursor": { 435 - "type": "string", 436 - "description": "A parameter used for pagination" 437 - }, 438 - "actors": { 439 - "type": "array", 440 - "items": { 441 - "$ref": "#/components/schemas/social.clippr.actor.defs.profileView" 442 - } 443 - } 444 - } 445 - } 446 - } 447 - } 448 - }, 449 - "400": { 450 - "description": "Bad Request", 451 - "content": { 452 - "application/json": { 453 - "schema": { 454 - "type": "object", 455 - "properties": { 456 - "error": { 457 - "type": "string", 458 - "description": "A general error code", 459 - "oneOf": [ 460 - { 461 - "const": "InvalidRequest" 462 - } 463 - ] 464 - }, 465 - "message": { 466 - "type": "string", 467 - "description": "A detailed description of the error" 468 - } 469 - } 470 - } 471 - } 472 - } 473 - } 474 - } 475 - } 476 - }, 477 - "/xrpc/social.clippr.actor.searchTags": { 478 - "get": { 479 - "tags": [ 480 - "Tags" 481 - ], 482 - "summary": "Search tags", 483 - "operationId": "social.clippr.actor.searchTags", 484 - "description": "Find tags matching search criteria.", 485 - "parameters": [ 486 - { 487 - "name": "q", 488 - "in": "query", 489 - "description": "Search query string", 490 - "required": true, 491 - "schema": { 492 - "type": "string", 493 - "description": "Search query string" 494 - } 495 - }, 496 - { 497 - "name": "limit", 498 - "in": "query", 499 - "description": "How many tags to return in the query output", 500 - "required": false, 501 - "schema": { 502 - "type": "integer", 503 - "minimum": 1, 504 - "maximum": 100, 505 - "default": 25 506 - } 507 - }, 508 - { 509 - "name": "actor", 510 - "in": "query", 511 - "description": "An actor to filter results to", 512 - "required": false, 513 - "schema": { 514 - "type": "string", 515 - "description": "An actor to filter results to", 516 - "format": "at-identifier" 517 - } 518 - }, 519 - { 520 - "name": "cursor", 521 - "in": "query", 522 - "description": "A parameter to paginate results", 523 - "required": false, 524 - "schema": { 525 - "type": "string", 526 - "description": "A parameter to paginate results" 527 - } 528 - } 529 - ], 530 - "responses": { 531 - "200": { 532 - "description": "OK", 533 - "content": { 534 - "application/json": { 535 - "schema": { 536 - "type": "object", 537 - "properties": { 538 - "cursor": { 539 - "type": "string", 540 - "description": "A parameter to paginate results" 541 - }, 542 - "tags": { 543 - "type": "array", 544 - "items": { 545 - "$ref": "#/components/schemas/social.clippr.feed.defs.tagView" 546 - } 547 - } 548 - } 549 - } 550 - } 551 - } 552 - }, 553 - "400": { 554 - "description": "Bad Request", 555 - "content": { 556 - "application/json": { 557 - "schema": { 558 - "type": "object", 559 - "properties": { 560 - "error": { 561 - "type": "string", 562 - "description": "A general error code", 563 - "oneOf": [ 564 - { 565 - "const": "InvalidRequest" 566 - } 567 - ] 568 - }, 569 - "message": { 570 - "type": "string", 571 - "description": "A detailed description of the error" 572 - } 573 - } 574 - } 575 - } 576 - } 577 - } 578 - } 579 - } 580 - }, 581 - "/xrpc/social.clippr.feed.getClips": { 582 - "get": { 583 - "tags": [ 584 - "Clips" 585 - ], 586 - "summary": "Get clips", 587 - "operationId": "social.clippr.feed.getClips", 588 - "description": "Get the hydrated views of a list of clips from their AT URIs.", 589 - "parameters": [ 590 - { 591 - "name": "uris", 592 - "in": "query", 593 - "description": "List of tag AT-URIs to return hydrated views for", 594 - "required": true, 595 - "schema": { 596 - "type": "array", 597 - "items": { 598 - "type": "string", 599 - "format": "at-uri" 600 - }, 601 - "maxItems": 25 602 - } 603 - } 604 - ], 605 - "responses": { 606 - "200": { 607 - "description": "OK", 608 - "content": { 609 - "application/json": { 610 - "schema": { 611 - "type": "array", 612 - "items": { 613 - "$ref": "#/components/schemas/social.clippr.feed.defs.clipView" 614 - } 615 - } 616 - } 617 - } 618 - }, 619 - "400": { 620 - "description": "Bad Request", 621 - "content": { 622 - "application/json": { 623 - "schema": { 624 - "type": "object", 625 - "properties": { 626 - "error": { 627 - "type": "string", 628 - "description": "A general error code", 629 - "oneOf": [ 630 - { 631 - "const": "InvalidRequest" 632 - } 633 - ] 634 - }, 635 - "message": { 636 - "type": "string", 637 - "description": "A detailed description of the error" 638 - } 639 - } 640 - } 641 - } 642 - } 643 - } 644 - } 645 - } 646 - }, 647 - "/xrpc/social.clippr.feed.getTags": { 648 - "get": { 649 - "tags": [ 650 - "Tags" 651 - ], 652 - "summary": "Get tags", 653 - "operationId": "social.clippr.feed.getTags", 654 - "description": "Get a the hydrated views of a list of tags from their AT URIs.", 655 - "parameters": [ 656 - { 657 - "name": "uris", 658 - "in": "query", 659 - "description": "List of tag AT-URIs to return hydrated views for", 660 - "required": true, 661 - "schema": { 662 - "type": "array", 663 - "items": { 664 - "type": "string", 665 - "format": "at-uri" 666 - }, 667 - "maxItems": 25 668 - } 669 - } 670 - ], 671 - "responses": { 672 - "200": { 673 - "description": "OK", 674 - "content": { 675 - "application/json": { 676 - "schema": { 677 - "type": "array", 678 - "items": { 679 - "$ref": "#/components/schemas/social.clippr.feed.defs.tagView" 680 - } 681 - } 682 - } 683 - } 684 - }, 685 - "400": { 686 - "description": "Bad Request", 687 - "content": { 688 - "application/json": { 689 - "schema": { 690 - "type": "object", 691 - "properties": { 692 - "error": { 693 - "type": "string", 694 - "description": "A general error code", 695 - "oneOf": [ 696 - { 697 - "const": "InvalidRequest" 698 - } 699 - ] 700 - }, 701 - "message": { 702 - "type": "string", 703 - "description": "A detailed description of the error" 704 - } 705 - } 706 - } 707 - } 708 - } 709 - } 710 - } 711 - } 712 - }, 713 - "/xrpc/social.clippr.feed.getProfileClips": { 714 - "get": { 715 - "tags": [ 716 - "Clips" 717 - ], 718 - "summary": "Get a profile's clip feed", 719 - "operationId": "social.clippr.feed.getProfileClips", 720 - "description": "Get a view of a profile's reverse-chronological clips feed.", 721 - "parameters": [ 722 - { 723 - "name": "actor", 724 - "in": "query", 725 - "description": "An actor to get feed data from", 726 - "required": true, 727 - "schema": { 728 - "type": "string", 729 - "description": "An actor to get feed data from", 730 - "format": "at-identifier" 731 - } 732 - }, 733 - { 734 - "name": "limit", 735 - "in": "query", 736 - "description": "How many results to return with the query", 737 - "required": false, 738 - "schema": { 739 - "type": "integer", 740 - "minimum": 1, 741 - "maximum": 100, 742 - "default": 50 743 - } 744 - }, 745 - { 746 - "name": "cursor", 747 - "in": "query", 748 - "description": "A parameter to paginate results", 749 - "required": false, 750 - "schema": { 751 - "type": "string", 752 - "description": "A parameter to paginate results" 753 - } 754 - }, 755 - { 756 - "name": "filter", 757 - "in": "query", 758 - "description": "What types to include in response", 759 - "required": false, 760 - "schema": { 761 - "type": "string", 762 - "description": "What types of clips to include in response", 763 - "default": "all_clips", 764 - "enum": [ 765 - "all_clips", 766 - "tagged_clips", 767 - "untagged_clips" 768 - ] 769 - } 770 - } 771 - ], 772 - "responses": { 773 - "200": { 774 - "description": "OK", 775 - "content": { 776 - "application/json": { 777 - "schema": { 778 - "type": "object", 779 - "properties": { 780 - "cursor": { 781 - "type": "string" 782 - }, 783 - "feed": { 784 - "type": "array", 785 - "items": { 786 - "$ref": "#/components/schemas/social.clippr.feed.defs.clipView" 787 - } 788 - } 789 - } 790 - } 791 - } 792 - } 793 - }, 794 - "400": { 795 - "description": "Bad Request", 796 - "content": { 797 - "application/json": { 798 - "schema": { 799 - "type": "object", 800 - "properties": { 801 - "error": { 802 - "type": "string", 803 - "description": "A general error code", 804 - "oneOf": [ 805 - { 806 - "const": "InvalidRequest" 807 - } 808 - ] 809 - }, 810 - "message": { 811 - "type": "string", 812 - "description": "A detailed description of the error" 813 - } 814 - } 815 - } 816 - } 817 - } 818 - } 819 - } 820 - } 821 - }, 822 - "/xrpc/social.clippr.feed.getProfileTags": { 823 - "get": { 824 - "tags": [ 825 - "Tags" 826 - ], 827 - "summary": "Get a profile's tag feed", 828 - "operationId": "social.clippr.feed.getProfileTags", 829 - "description": "Get a view of a profile's reverse-chronological clips feed.", 830 - "parameters": [ 831 - { 832 - "name": "actor", 833 - "in": "query", 834 - "description": "An actor to get feed data from", 835 - "required": true, 836 - "schema": { 837 - "type": "string", 838 - "description": "An actor to get feed data from", 839 - "format": "at-identifier" 840 - } 841 - }, 842 - { 843 - "name": "limit", 844 - "in": "query", 845 - "description": "How many results to return with the query", 846 - "required": false, 847 - "schema": { 848 - "type": "integer", 849 - "minimum": 1, 850 - "maximum": 100, 851 - "default": 50 852 - } 853 - }, 854 - { 855 - "name": "cursor", 856 - "in": "query", 857 - "description": "A parameter to paginate results", 858 - "required": false, 859 - "schema": { 860 - "type": "string", 861 - "description": "A parameter to paginate results" 862 - } 863 - } 864 - ], 865 - "responses": { 866 - "200": { 867 - "description": "OK", 868 - "content": { 869 - "application/json": { 870 - "schema": { 871 - "type": "object", 872 - "properties": { 873 - "cursor": { 874 - "type": "string" 875 - }, 876 - "feed": { 877 - "type": "array", 878 - "items": { 879 - "$ref": "#/components/schemas/social.clippr.feed.defs.tagView" 880 - } 881 - } 882 - } 883 - } 884 - } 885 - } 886 - }, 887 - "400": { 888 - "description": "Bad Request", 889 - "content": { 890 - "application/json": { 891 - "schema": { 892 - "type": "object", 893 - "properties": { 894 - "error": { 895 - "type": "string", 896 - "description": "A general error code", 897 - "oneOf": [ 898 - { 899 - "const": "InvalidRequest" 900 - } 901 - ] 902 - }, 903 - "message": { 904 - "type": "string", 905 - "description": "A detailed description of the error" 906 - } 907 - } 908 - } 909 - } 910 - } 911 - } 912 - } 913 - } 914 - }, 915 - "/xrpc/social.clippr.feed.getTagList": { 916 - "get": { 917 - "tags": [ 918 - "Tags" 919 - ], 920 - "summary": "Get a profile's tag list", 921 - "operationId": "social.clippr.feed.getProfileTags", 922 - "description": "Get a profile's complete list of tags.", 923 - "parameters": [ 924 - { 925 - "name": "actor", 926 - "in": "query", 927 - "description": "An actor to fetch the tag list from", 928 - "required": false, 929 - "schema": { 930 - "type": "string", 931 - "description": "An actor to fetch the tag list from", 932 - "format": "at-identifier" 933 - } 934 - } 935 - ], 936 - "responses": { 937 - "200": { 938 - "description": "OK", 939 - "content": { 940 - "application/json": { 941 - "schema": { 942 - "type": "object", 943 - "properties": { 944 - "tags": { 945 - "type": "array", 946 - "items": { 947 - "$ref": "#/components/schemas/social.clippr.feed.defs.tagView" 948 - } 949 - } 950 - } 951 - } 952 - } 953 - } 954 - }, 955 - "400": { 956 - "description": "Bad Request", 957 - "content": { 958 - "application/json": { 959 - "schema": { 960 - "type": "object", 961 - "properties": { 962 - "error": { 963 - "type": "string", 964 - "description": "A general error code", 965 - "oneOf": [ 966 - { 967 - "error": "InvalidRequest" 968 - } 969 - ] 970 - }, 971 - "message": { 972 - "type": "string", 973 - "description": "A detailed description of the error" 974 - } 975 - } 976 - } 977 - } 978 - } 979 - } 980 - } 981 - } 982 - }, 983 - "/xrpc/_health": { 984 - "get": { 985 - "summary": "Health check", 986 - "description": "Check the health of the server. If it is functioning properly, you will receive the server's version number.", 987 - "responses": { 988 - "200": { 989 - "description": "OK", 990 - "content": { 991 - "application/json": { 992 - "schema": { 993 - "type": "object", 994 - "properties": { 995 - "version": { 996 - "type": "string", 997 - "description": "The version number of the AppView." 998 - } 999 - } 1000 - } 1001 - } 1002 - } 1003 - } 1004 - }, 1005 - "tags": [ 1006 - "Misc" 1007 - ] 1008 - } 1009 - } 1010 - }, 1011 - "components": { 1012 - "schemas": { 1013 - "com.atproto.repo.strongRef": { 1014 - "type": "object", 1015 - "required": [ 1016 - "uri", 1017 - "cid" 1018 - ], 1019 - "properties": { 1020 - "uri": { 1021 - "type": "string", 1022 - "format": "at-uri" 1023 - }, 1024 - "cid": { 1025 - "type": "string", 1026 - "format": "cid" 1027 - } 1028 - } 1029 - }, 1030 - "social.clippr.actor.defs.profileView": { 1031 - "type": "object", 1032 - "description": "A view of an actor's profile", 1033 - "required": [ 1034 - "did", 1035 - "handle", 1036 - "displayName" 1037 - ], 1038 - "properties": { 1039 - "did": { 1040 - "type": "string", 1041 - "description": "The DID of the profile", 1042 - "format": "did" 1043 - }, 1044 - "handle": { 1045 - "type": "string", 1046 - "description": "The handle of the profile", 1047 - "format": "handle" 1048 - }, 1049 - "displayName": { 1050 - "type": "string", 1051 - "description": "The display name associated to the profile", 1052 - "maxLength": 64 1053 - }, 1054 - "description": { 1055 - "type": "string", 1056 - "description": "The biography associated to the profile", 1057 - "maxLength": 500 1058 - }, 1059 - "avatar": { 1060 - "type": "string", 1061 - "description": "A link to the profile's avatar", 1062 - "format": "uri" 1063 - }, 1064 - "createdAt": { 1065 - "type": "string", 1066 - "description": "When the profile record was first created", 1067 - "format": "date-time" 1068 - } 1069 - } 1070 - }, 1071 - "social.clippr.actor.defs.preferences": { 1072 - "type": "array", 1073 - "items": { 1074 - "oneOf": [ 1075 - { 1076 - "$ref": "#/components/schemas/social.clippr.actor.defs.publishingScopesPref" 1077 - } 1078 - ] 1079 - } 1080 - }, 1081 - "social.clippr.actor.defs.publishingScopesPref": { 1082 - "type": "object", 1083 - "description": "Preferences for a user's publishing scopes", 1084 - "required": [ 1085 - "defaultScope" 1086 - ], 1087 - "properties": { 1088 - "defaultScope": { 1089 - "type": "string", 1090 - "description": "What publishing scope to mark a clip as by default", 1091 - "enum": [ 1092 - "public", 1093 - "unlisted" 1094 - ] 1095 - } 1096 - } 1097 - }, 1098 - "social.clippr.feed.defs.clipView": { 1099 - "type": "object", 1100 - "description": "A view of a single bookmark (or 'clip')", 1101 - "required": [ 1102 - "uri", 1103 - "cid", 1104 - "author", 1105 - "record", 1106 - "indexedAt" 1107 - ], 1108 - "properties": { 1109 - "uri": { 1110 - "type": "string", 1111 - "description": "The AT-URI of the clip", 1112 - "format": "at-uri" 1113 - }, 1114 - "cid": { 1115 - "type": "string", 1116 - "description": "The CID of the clip", 1117 - "format": "cid" 1118 - }, 1119 - "author": { 1120 - "description": "A reference to the actor's profile", 1121 - "$ref": "#/components/schemas/social.clippr.actor.defs.profileView" 1122 - }, 1123 - "record": { 1124 - "type": "object", 1125 - "description": "The raw record of the clip" 1126 - }, 1127 - "indexedAt": { 1128 - "type": "string", 1129 - "description": "The time in which the clip's record was indexed by the AppView", 1130 - "format": "date-time" 1131 - } 1132 - } 1133 - }, 1134 - "social.clippr.feed.defs.tagView": { 1135 - "type": "object", 1136 - "description": "A view of a single tag", 1137 - "required": [ 1138 - "uri", 1139 - "cid", 1140 - "author", 1141 - "record", 1142 - "indexedAt" 1143 - ], 1144 - "properties": { 1145 - "uri": { 1146 - "type": "string", 1147 - "description": "The AT-URI to the tag", 1148 - "format": "at-uri" 1149 - }, 1150 - "cid": { 1151 - "type": "string", 1152 - "description": "The CID of the tag", 1153 - "format": "cid" 1154 - }, 1155 - "author": { 1156 - "description": "A reference to the actor's profile", 1157 - "$ref": "#/components/schemas/social.clippr.actor.defs.profileView" 1158 - }, 1159 - "record": { 1160 - "type": "object", 1161 - "description": "The raw record of the clip" 1162 - }, 1163 - "indexedAt": { 1164 - "type": "string", 1165 - "description": "The time in which the tag's record was indexed by the AppView", 1166 - "format": "date-time" 1167 - } 1168 - } 1169 - }, 1170 - "social.clippr.actor.profile": { 1171 - "type": "object", 1172 - "required": [ 1173 - "createdAt", 1174 - "displayName" 1175 - ], 1176 - "properties": { 1177 - "displayName": { 1178 - "type": "string", 1179 - "description": "A display name to be shown on a profile", 1180 - "maxLength": 64 1181 - }, 1182 - "description": { 1183 - "type": "string", 1184 - "description": "Text for user to describe themselves", 1185 - "maxLength": 500 1186 - }, 1187 - "avatar": { 1188 - "type": "blob", 1189 - "maxSize": 1000000, 1190 - "description": "Image to show on user's profiles" 1191 - }, 1192 - "createdAt": { 1193 - "type": "string", 1194 - "description": "The creation date of the profile", 1195 - "format": "date-time" 1196 - } 1197 - } 1198 - }, 1199 - "social.clippr.feed.clip": { 1200 - "type": "object", 1201 - "required": [ 1202 - "url", 1203 - "title", 1204 - "description", 1205 - "unlisted", 1206 - "createdAt" 1207 - ], 1208 - "properties": { 1209 - "url": { 1210 - "type": "string", 1211 - "description": "The URL of the bookmark. Cannot be left empty or be modified after creation.", 1212 - "format": "uri", 1213 - "maxLength": 2000 1214 - }, 1215 - "title": { 1216 - "type": "string", 1217 - "description": "The title of the bookmark. If left empty, reuse the URL.", 1218 - "maxLength": 2048 1219 - }, 1220 - "description": { 1221 - "type": "string", 1222 - "description": "A description of the bookmark's content. This should be ripped from the URL metadata and be static for all records using the URL.", 1223 - "maxLength": 4096 1224 - }, 1225 - "notes": { 1226 - "type": "string", 1227 - "description": "User-written notes for the bookmark. Public and personal.", 1228 - "maxLength": 10000 1229 - }, 1230 - "tags": { 1231 - "type": "array", 1232 - "description": "An array of tags. A format of solely alphanumeric characters and dashes should be used.", 1233 - "items": { 1234 - "$ref": "#/components/schemas/com.atproto.repo.strongRef" 1235 - } 1236 - }, 1237 - "unlisted": { 1238 - "type": "boolean", 1239 - "description": "Whether the bookmark can be used for feed indexing and aggregation" 1240 - }, 1241 - "unread": { 1242 - "type": "boolean", 1243 - "description": "Whether the bookmark has been read by the user", 1244 - "default": true 1245 - }, 1246 - "languages": { 1247 - "type": "array", 1248 - "items": { 1249 - "type": "string", 1250 - "format": "language" 1251 - }, 1252 - "maxItems": 5 1253 - }, 1254 - "createdAt": { 1255 - "type": "string", 1256 - "description": "Client-declared timestamp when the bookmark is created", 1257 - "format": "date-time" 1258 - } 1259 - } 1260 - }, 1261 - "social.clippr.feed.tag": { 1262 - "type": "object", 1263 - "required": [ 1264 - "name", 1265 - "createdAt" 1266 - ], 1267 - "properties": { 1268 - "name": { 1269 - "type": "string", 1270 - "description": "A de-duplicated string containing the name of the tag", 1271 - "maxLength": 64 1272 - }, 1273 - "color": { 1274 - "type": "string", 1275 - "description": "A hexadecimal color code", 1276 - "maxLength": 7 1277 - }, 1278 - "description": { 1279 - "type": "string", 1280 - "description": "A description of the tag for additional context", 1281 - "maxLength": 5000 1282 - }, 1283 - "createdAt": { 1284 - "type": "string", 1285 - "description": "A client-defined timestamp for the creation of the tag", 1286 - "format": "date-time" 1287 - } 1288 - } 1289 - } 1290 - } 1291 - } 2 + "openapi": "3.1.1", 3 + "info": { 4 + "title": "Clippr AppView API", 5 + "version": "1.0.1", 6 + "description": "API reference documentation for Clippr's backend.", 7 + "license": { 8 + "name": "GNU Affero General Public License v3.0 only", 9 + "identifier": "AGPL-3.0-only" 10 + } 11 + }, 12 + "servers": [ 13 + { 14 + "url": "http://localhost:9090", 15 + "description": "Development server" 16 + }, 17 + { 18 + "url": "https://api.clippr.social", 19 + "description": "Production server" 20 + } 21 + ], 22 + "tags": [ 23 + { 24 + "name": "Clips", 25 + "description": "API paths that relate to user bookmarks, or 'clips'." 26 + }, 27 + { 28 + "name": "Tags", 29 + "description": "API paths that relate to user tags." 30 + }, 31 + { 32 + "name": "Profile", 33 + "description": "API paths that relate to user profiles." 34 + }, 35 + { 36 + "name": "Misc", 37 + "description": "API paths that don't fit into any other category." 38 + } 39 + ], 40 + "paths": { 41 + "/xrpc/social.clippr.actor.getPreferences": { 42 + "get": { 43 + "tags": ["Profile"], 44 + "summary": "Get a user's preferences", 45 + "operationId": "social.clippr.actor.getPreferences", 46 + "description": "Get a user's private preferences. Requires authentication.", 47 + "security": [ 48 + { 49 + "Bearer": [] 50 + } 51 + ], 52 + "responses": { 53 + "200": { 54 + "description": "OK", 55 + "content": { 56 + "application/json": { 57 + "schema": { 58 + "$ref": "#/components/schemas/social.clippr.actor.defs.preferences" 59 + } 60 + } 61 + } 62 + }, 63 + "400": { 64 + "description": "Bad Request", 65 + "content": { 66 + "application/json": { 67 + "schema": { 68 + "type": "object", 69 + "properties": { 70 + "error": { 71 + "type": "string", 72 + "description": "A general error code", 73 + "oneOf": [ 74 + { 75 + "const": "InvalidRequest" 76 + }, 77 + { 78 + "const": "ExpiredToken" 79 + }, 80 + { 81 + "const": "InvalidToken" 82 + } 83 + ] 84 + }, 85 + "message": { 86 + "type": "string", 87 + "description": "A detailed description of the error" 88 + } 89 + } 90 + } 91 + } 92 + } 93 + }, 94 + "401": { 95 + "description": "Unauthorized", 96 + "content": { 97 + "application/json": { 98 + "schema": { 99 + "type": "object", 100 + "properties": { 101 + "error": { 102 + "type": "string", 103 + "description": "A general error code", 104 + "oneOf": [ 105 + { 106 + "const": "AuthMissing" 107 + } 108 + ] 109 + }, 110 + "message": { 111 + "type": "string", 112 + "description": "A detailed description of the error" 113 + } 114 + } 115 + } 116 + } 117 + } 118 + } 119 + } 120 + } 121 + }, 122 + "/xrpc/social.clippr.actor.getProfile": { 123 + "get": { 124 + "tags": ["Profile"], 125 + "summary": "Get a profile", 126 + "operationId": "social.clippr.actor.getProfile", 127 + "description": "Get a user's profile based on a given DID or handle.", 128 + "parameters": [ 129 + { 130 + "name": "actor", 131 + "in": "query", 132 + "description": "Handle or DID of account to fetch profile of", 133 + "required": true, 134 + "content": { 135 + "schema": { 136 + "type": "string", 137 + "description": "Handle or DID of account to fetch profile of", 138 + "format": "at-identifier" 139 + } 140 + }, 141 + "deprecated": false, 142 + "allowEmptyValue": false 143 + } 144 + ], 145 + "responses": { 146 + "200": { 147 + "description": "OK", 148 + "content": { 149 + "application/json": { 150 + "schema": { 151 + "type": "object", 152 + "$ref": "#/components/schemas/social.clippr.actor.defs.profileView" 153 + } 154 + } 155 + } 156 + }, 157 + "400": { 158 + "description": "Bad Request", 159 + "content": { 160 + "application/json": { 161 + "schema": { 162 + "type": "object", 163 + "properties": { 164 + "error": { 165 + "type": "string", 166 + "description": "A general error code", 167 + "oneOf": [ 168 + { 169 + "const": "InvalidRequest" 170 + } 171 + ] 172 + }, 173 + "message": { 174 + "type": "string", 175 + "description": "A detailed description of the error" 176 + } 177 + } 178 + } 179 + } 180 + } 181 + } 182 + } 183 + } 184 + }, 185 + "/xrpc/social.clippr.actor.putPreferences": { 186 + "post": { 187 + "tags": ["Profile"], 188 + "summary": "Set a user's preferences", 189 + "operationId": "social.clippr.actor.putPreferences", 190 + "description": "Sets the private preferences attached to the account. Requires authentication.", 191 + "security": [ 192 + { 193 + "Bearer": [] 194 + } 195 + ], 196 + "requestBody": { 197 + "required": true, 198 + "content": { 199 + "application/json": { 200 + "schema": { 201 + "type": "object", 202 + "properties": { 203 + "preferences": { 204 + "$ref": "#/components/schemas/social.clippr.actor.defs.preferences" 205 + } 206 + } 207 + } 208 + } 209 + } 210 + }, 211 + "responses": { 212 + "200": { 213 + "description": "OK" 214 + }, 215 + "400": { 216 + "description": "Bad Request", 217 + "content": { 218 + "application/json": { 219 + "schema": { 220 + "type": "object", 221 + "properties": { 222 + "error": { 223 + "type": "string", 224 + "oneOf": [ 225 + { 226 + "const": "InvalidRequest" 227 + }, 228 + { 229 + "const": "ExpiredToken" 230 + }, 231 + { 232 + "const": "InvalidToken" 233 + } 234 + ], 235 + "description": "A general error code" 236 + }, 237 + "message": { 238 + "type": "string", 239 + "description": "A detailed description of the error" 240 + } 241 + } 242 + } 243 + } 244 + } 245 + }, 246 + "401": { 247 + "description": "Unauthorized", 248 + "content": { 249 + "application/json": { 250 + "schema": { 251 + "type": "object", 252 + "properties": { 253 + "error": { 254 + "type": "string", 255 + "description": "A general error code", 256 + "oneOf": [ 257 + { 258 + "const": "AuthMissing" 259 + } 260 + ] 261 + }, 262 + "message": { 263 + "type": "string", 264 + "description": "A detailed description of the error" 265 + } 266 + } 267 + } 268 + } 269 + } 270 + } 271 + } 272 + } 273 + }, 274 + "/xrpc/social.clippr.actor.searchClips": { 275 + "get": { 276 + "tags": ["Clips"], 277 + "summary": "Search clips", 278 + "operationId": "social.clippr.actor.searchClips", 279 + "description": "Find clips matching search criteria.", 280 + "parameters": [ 281 + { 282 + "name": "q", 283 + "in": "query", 284 + "description": "Search query string", 285 + "required": true, 286 + "schema": { 287 + "type": "string", 288 + "description": "Search query string" 289 + } 290 + }, 291 + { 292 + "name": "limit", 293 + "in": "query", 294 + "description": "How many clips to return in the query output", 295 + "required": false, 296 + "schema": { 297 + "type": "integer", 298 + "minimum": 1, 299 + "maximum": 100, 300 + "default": 25 301 + } 302 + }, 303 + { 304 + "name": "actor", 305 + "in": "query", 306 + "description": "An actor to filter results to", 307 + "required": false, 308 + "schema": { 309 + "type": "string", 310 + "description": "An actor to filter results to", 311 + "format": "at-identifier" 312 + } 313 + }, 314 + { 315 + "name": "cursor", 316 + "in": "query", 317 + "description": "A parameter to paginate results", 318 + "required": false, 319 + "schema": { 320 + "type": "string", 321 + "description": "A parameter to paginate results" 322 + } 323 + } 324 + ], 325 + "responses": { 326 + "200": { 327 + "description": "OK", 328 + "content": { 329 + "application/json": { 330 + "schema": { 331 + "type": "object", 332 + "properties": { 333 + "cursor": { 334 + "type": "string", 335 + "description": "A parameter to paginate results" 336 + }, 337 + "clips": { 338 + "type": "array", 339 + "items": { 340 + "$ref": "#/components/schemas/social.clippr.feed.defs.clipView" 341 + } 342 + } 343 + } 344 + } 345 + } 346 + } 347 + }, 348 + "400": { 349 + "description": "Bad Request", 350 + "content": { 351 + "application/json": { 352 + "schema": { 353 + "type": "object", 354 + "properties": { 355 + "error": { 356 + "type": "string", 357 + "description": "A general error code", 358 + "oneOf": [ 359 + { 360 + "const": "InvalidRequest" 361 + } 362 + ] 363 + }, 364 + "message": { 365 + "type": "string", 366 + "description": "A detailed description of the error" 367 + } 368 + } 369 + } 370 + } 371 + } 372 + } 373 + } 374 + } 375 + }, 376 + "/xrpc/social.clippr.actor.searchProfiles": { 377 + "get": { 378 + "tags": ["Profile"], 379 + "summary": "Search profiles", 380 + "operationId": "social.clippr.actor.searchProfiles", 381 + "description": "Find profiles matching search criteria.", 382 + "parameters": [ 383 + { 384 + "name": "q", 385 + "in": "query", 386 + "description": "Search query string", 387 + "required": false, 388 + "schema": { 389 + "type": "string", 390 + "description": "Search query string" 391 + } 392 + }, 393 + { 394 + "name": "limit", 395 + "in": "query", 396 + "description": "The number of profiles to be returned in the query", 397 + "required": false, 398 + "schema": { 399 + "type": "integer", 400 + "minimum": 1, 401 + "maximum": 100, 402 + "default": 25 403 + } 404 + }, 405 + { 406 + "name": "cursor", 407 + "in": "query", 408 + "description": "A parameter used for pagination", 409 + "required": false, 410 + "schema": { 411 + "type": "string", 412 + "description": "A parameter used for pagination" 413 + } 414 + } 415 + ], 416 + "responses": { 417 + "200": { 418 + "description": "OK", 419 + "content": { 420 + "application/json": { 421 + "schema": { 422 + "type": "object", 423 + "properties": { 424 + "cursor": { 425 + "type": "string", 426 + "description": "A parameter used for pagination" 427 + }, 428 + "actors": { 429 + "type": "array", 430 + "items": { 431 + "$ref": "#/components/schemas/social.clippr.actor.defs.profileView" 432 + } 433 + } 434 + } 435 + } 436 + } 437 + } 438 + }, 439 + "400": { 440 + "description": "Bad Request", 441 + "content": { 442 + "application/json": { 443 + "schema": { 444 + "type": "object", 445 + "properties": { 446 + "error": { 447 + "type": "string", 448 + "description": "A general error code", 449 + "oneOf": [ 450 + { 451 + "const": "InvalidRequest" 452 + } 453 + ] 454 + }, 455 + "message": { 456 + "type": "string", 457 + "description": "A detailed description of the error" 458 + } 459 + } 460 + } 461 + } 462 + } 463 + } 464 + } 465 + } 466 + }, 467 + "/xrpc/social.clippr.actor.searchTags": { 468 + "get": { 469 + "tags": ["Tags"], 470 + "summary": "Search tags", 471 + "operationId": "social.clippr.actor.searchTags", 472 + "description": "Find tags matching search criteria.", 473 + "parameters": [ 474 + { 475 + "name": "q", 476 + "in": "query", 477 + "description": "Search query string", 478 + "required": true, 479 + "schema": { 480 + "type": "string", 481 + "description": "Search query string" 482 + } 483 + }, 484 + { 485 + "name": "limit", 486 + "in": "query", 487 + "description": "How many tags to return in the query output", 488 + "required": false, 489 + "schema": { 490 + "type": "integer", 491 + "minimum": 1, 492 + "maximum": 100, 493 + "default": 25 494 + } 495 + }, 496 + { 497 + "name": "actor", 498 + "in": "query", 499 + "description": "An actor to filter results to", 500 + "required": false, 501 + "schema": { 502 + "type": "string", 503 + "description": "An actor to filter results to", 504 + "format": "at-identifier" 505 + } 506 + }, 507 + { 508 + "name": "cursor", 509 + "in": "query", 510 + "description": "A parameter to paginate results", 511 + "required": false, 512 + "schema": { 513 + "type": "string", 514 + "description": "A parameter to paginate results" 515 + } 516 + } 517 + ], 518 + "responses": { 519 + "200": { 520 + "description": "OK", 521 + "content": { 522 + "application/json": { 523 + "schema": { 524 + "type": "object", 525 + "properties": { 526 + "cursor": { 527 + "type": "string", 528 + "description": "A parameter to paginate results" 529 + }, 530 + "tags": { 531 + "type": "array", 532 + "items": { 533 + "$ref": "#/components/schemas/social.clippr.feed.defs.tagView" 534 + } 535 + } 536 + } 537 + } 538 + } 539 + } 540 + }, 541 + "400": { 542 + "description": "Bad Request", 543 + "content": { 544 + "application/json": { 545 + "schema": { 546 + "type": "object", 547 + "properties": { 548 + "error": { 549 + "type": "string", 550 + "description": "A general error code", 551 + "oneOf": [ 552 + { 553 + "const": "InvalidRequest" 554 + } 555 + ] 556 + }, 557 + "message": { 558 + "type": "string", 559 + "description": "A detailed description of the error" 560 + } 561 + } 562 + } 563 + } 564 + } 565 + } 566 + } 567 + } 568 + }, 569 + "/xrpc/social.clippr.feed.getClips": { 570 + "get": { 571 + "tags": ["Clips"], 572 + "summary": "Get clips", 573 + "operationId": "social.clippr.feed.getClips", 574 + "description": "Get the hydrated views of a list of clips from their AT URIs.", 575 + "parameters": [ 576 + { 577 + "name": "uris", 578 + "in": "query", 579 + "description": "List of tag AT-URIs to return hydrated views for", 580 + "required": true, 581 + "schema": { 582 + "type": "array", 583 + "items": { 584 + "type": "string", 585 + "format": "at-uri" 586 + }, 587 + "maxItems": 25 588 + } 589 + } 590 + ], 591 + "responses": { 592 + "200": { 593 + "description": "OK", 594 + "content": { 595 + "application/json": { 596 + "schema": { 597 + "type": "array", 598 + "items": { 599 + "$ref": "#/components/schemas/social.clippr.feed.defs.clipView" 600 + } 601 + } 602 + } 603 + } 604 + }, 605 + "400": { 606 + "description": "Bad Request", 607 + "content": { 608 + "application/json": { 609 + "schema": { 610 + "type": "object", 611 + "properties": { 612 + "error": { 613 + "type": "string", 614 + "description": "A general error code", 615 + "oneOf": [ 616 + { 617 + "const": "InvalidRequest" 618 + } 619 + ] 620 + }, 621 + "message": { 622 + "type": "string", 623 + "description": "A detailed description of the error" 624 + } 625 + } 626 + } 627 + } 628 + } 629 + } 630 + } 631 + } 632 + }, 633 + "/xrpc/social.clippr.feed.getTags": { 634 + "get": { 635 + "tags": ["Tags"], 636 + "summary": "Get tags", 637 + "operationId": "social.clippr.feed.getTags", 638 + "description": "Get a the hydrated views of a list of tags from their AT URIs.", 639 + "parameters": [ 640 + { 641 + "name": "uris", 642 + "in": "query", 643 + "description": "List of tag AT-URIs to return hydrated views for", 644 + "required": true, 645 + "schema": { 646 + "type": "array", 647 + "items": { 648 + "type": "string", 649 + "format": "at-uri" 650 + }, 651 + "maxItems": 25 652 + } 653 + } 654 + ], 655 + "responses": { 656 + "200": { 657 + "description": "OK", 658 + "content": { 659 + "application/json": { 660 + "schema": { 661 + "type": "array", 662 + "items": { 663 + "$ref": "#/components/schemas/social.clippr.feed.defs.tagView" 664 + } 665 + } 666 + } 667 + } 668 + }, 669 + "400": { 670 + "description": "Bad Request", 671 + "content": { 672 + "application/json": { 673 + "schema": { 674 + "type": "object", 675 + "properties": { 676 + "error": { 677 + "type": "string", 678 + "description": "A general error code", 679 + "oneOf": [ 680 + { 681 + "const": "InvalidRequest" 682 + } 683 + ] 684 + }, 685 + "message": { 686 + "type": "string", 687 + "description": "A detailed description of the error" 688 + } 689 + } 690 + } 691 + } 692 + } 693 + } 694 + } 695 + } 696 + }, 697 + "/xrpc/social.clippr.feed.getProfileClips": { 698 + "get": { 699 + "tags": ["Clips"], 700 + "summary": "Get a profile's clip feed", 701 + "operationId": "social.clippr.feed.getProfileClips", 702 + "description": "Get a view of a profile's reverse-chronological clips feed.", 703 + "parameters": [ 704 + { 705 + "name": "actor", 706 + "in": "query", 707 + "description": "An actor to get feed data from", 708 + "required": true, 709 + "schema": { 710 + "type": "string", 711 + "description": "An actor to get feed data from", 712 + "format": "at-identifier" 713 + } 714 + }, 715 + { 716 + "name": "limit", 717 + "in": "query", 718 + "description": "How many results to return with the query", 719 + "required": false, 720 + "schema": { 721 + "type": "integer", 722 + "minimum": 1, 723 + "maximum": 100, 724 + "default": 50 725 + } 726 + }, 727 + { 728 + "name": "cursor", 729 + "in": "query", 730 + "description": "A parameter to paginate results", 731 + "required": false, 732 + "schema": { 733 + "type": "string", 734 + "description": "A parameter to paginate results" 735 + } 736 + }, 737 + { 738 + "name": "filter", 739 + "in": "query", 740 + "description": "What types to include in response", 741 + "required": false, 742 + "schema": { 743 + "type": "string", 744 + "description": "What types of clips to include in response", 745 + "default": "all_clips", 746 + "enum": ["all_clips", "tagged_clips", "untagged_clips"] 747 + } 748 + } 749 + ], 750 + "responses": { 751 + "200": { 752 + "description": "OK", 753 + "content": { 754 + "application/json": { 755 + "schema": { 756 + "type": "object", 757 + "properties": { 758 + "cursor": { 759 + "type": "string" 760 + }, 761 + "feed": { 762 + "type": "array", 763 + "items": { 764 + "$ref": "#/components/schemas/social.clippr.feed.defs.clipView" 765 + } 766 + } 767 + } 768 + } 769 + } 770 + } 771 + }, 772 + "400": { 773 + "description": "Bad Request", 774 + "content": { 775 + "application/json": { 776 + "schema": { 777 + "type": "object", 778 + "properties": { 779 + "error": { 780 + "type": "string", 781 + "description": "A general error code", 782 + "oneOf": [ 783 + { 784 + "const": "InvalidRequest" 785 + } 786 + ] 787 + }, 788 + "message": { 789 + "type": "string", 790 + "description": "A detailed description of the error" 791 + } 792 + } 793 + } 794 + } 795 + } 796 + } 797 + } 798 + } 799 + }, 800 + "/xrpc/social.clippr.feed.getProfileTags": { 801 + "get": { 802 + "tags": ["Tags"], 803 + "summary": "Get a profile's tag feed", 804 + "operationId": "social.clippr.feed.getProfileTags", 805 + "description": "Get a view of a profile's reverse-chronological clips feed.", 806 + "parameters": [ 807 + { 808 + "name": "actor", 809 + "in": "query", 810 + "description": "An actor to get feed data from", 811 + "required": true, 812 + "schema": { 813 + "type": "string", 814 + "description": "An actor to get feed data from", 815 + "format": "at-identifier" 816 + } 817 + }, 818 + { 819 + "name": "limit", 820 + "in": "query", 821 + "description": "How many results to return with the query", 822 + "required": false, 823 + "schema": { 824 + "type": "integer", 825 + "minimum": 1, 826 + "maximum": 100, 827 + "default": 50 828 + } 829 + }, 830 + { 831 + "name": "cursor", 832 + "in": "query", 833 + "description": "A parameter to paginate results", 834 + "required": false, 835 + "schema": { 836 + "type": "string", 837 + "description": "A parameter to paginate results" 838 + } 839 + } 840 + ], 841 + "responses": { 842 + "200": { 843 + "description": "OK", 844 + "content": { 845 + "application/json": { 846 + "schema": { 847 + "type": "object", 848 + "properties": { 849 + "cursor": { 850 + "type": "string" 851 + }, 852 + "feed": { 853 + "type": "array", 854 + "items": { 855 + "$ref": "#/components/schemas/social.clippr.feed.defs.tagView" 856 + } 857 + } 858 + } 859 + } 860 + } 861 + } 862 + }, 863 + "400": { 864 + "description": "Bad Request", 865 + "content": { 866 + "application/json": { 867 + "schema": { 868 + "type": "object", 869 + "properties": { 870 + "error": { 871 + "type": "string", 872 + "description": "A general error code", 873 + "oneOf": [ 874 + { 875 + "const": "InvalidRequest" 876 + } 877 + ] 878 + }, 879 + "message": { 880 + "type": "string", 881 + "description": "A detailed description of the error" 882 + } 883 + } 884 + } 885 + } 886 + } 887 + } 888 + } 889 + } 890 + }, 891 + "/xrpc/social.clippr.feed.getTagList": { 892 + "get": { 893 + "tags": ["Tags"], 894 + "summary": "Get a profile's tag list", 895 + "operationId": "social.clippr.feed.getProfileTags", 896 + "description": "Get a profile's complete list of tags.", 897 + "parameters": [ 898 + { 899 + "name": "actor", 900 + "in": "query", 901 + "description": "An actor to fetch the tag list from", 902 + "required": false, 903 + "schema": { 904 + "type": "string", 905 + "description": "An actor to fetch the tag list from", 906 + "format": "at-identifier" 907 + } 908 + } 909 + ], 910 + "responses": { 911 + "200": { 912 + "description": "OK", 913 + "content": { 914 + "application/json": { 915 + "schema": { 916 + "type": "object", 917 + "properties": { 918 + "tags": { 919 + "type": "array", 920 + "items": { 921 + "$ref": "#/components/schemas/social.clippr.feed.defs.tagView" 922 + } 923 + } 924 + } 925 + } 926 + } 927 + } 928 + }, 929 + "400": { 930 + "description": "Bad Request", 931 + "content": { 932 + "application/json": { 933 + "schema": { 934 + "type": "object", 935 + "properties": { 936 + "error": { 937 + "type": "string", 938 + "description": "A general error code", 939 + "oneOf": [ 940 + { 941 + "error": "InvalidRequest" 942 + } 943 + ] 944 + }, 945 + "message": { 946 + "type": "string", 947 + "description": "A detailed description of the error" 948 + } 949 + } 950 + } 951 + } 952 + } 953 + } 954 + } 955 + } 956 + }, 957 + "/xrpc/_health": { 958 + "get": { 959 + "summary": "Health check", 960 + "description": "Check the health of the server. If it is functioning properly, you will receive the server's version number.", 961 + "responses": { 962 + "200": { 963 + "description": "OK", 964 + "content": { 965 + "application/json": { 966 + "schema": { 967 + "type": "object", 968 + "properties": { 969 + "version": { 970 + "type": "string", 971 + "description": "The version number of the AppView." 972 + } 973 + } 974 + } 975 + } 976 + } 977 + } 978 + }, 979 + "tags": ["Misc"] 980 + } 981 + } 982 + }, 983 + "components": { 984 + "schemas": { 985 + "com.atproto.repo.strongRef": { 986 + "type": "object", 987 + "required": ["uri", "cid"], 988 + "properties": { 989 + "uri": { 990 + "type": "string", 991 + "format": "at-uri" 992 + }, 993 + "cid": { 994 + "type": "string", 995 + "format": "cid" 996 + } 997 + } 998 + }, 999 + "social.clippr.actor.defs.profileView": { 1000 + "type": "object", 1001 + "description": "A view of an actor's profile", 1002 + "required": ["did", "handle", "displayName"], 1003 + "properties": { 1004 + "did": { 1005 + "type": "string", 1006 + "description": "The DID of the profile", 1007 + "format": "did" 1008 + }, 1009 + "handle": { 1010 + "type": "string", 1011 + "description": "The handle of the profile", 1012 + "format": "handle" 1013 + }, 1014 + "displayName": { 1015 + "type": "string", 1016 + "description": "The display name associated to the profile", 1017 + "maxLength": 64 1018 + }, 1019 + "description": { 1020 + "type": "string", 1021 + "description": "The biography associated to the profile", 1022 + "maxLength": 500 1023 + }, 1024 + "avatar": { 1025 + "type": "string", 1026 + "description": "A link to the profile's avatar", 1027 + "format": "uri" 1028 + }, 1029 + "createdAt": { 1030 + "type": "string", 1031 + "description": "When the profile record was first created", 1032 + "format": "date-time" 1033 + } 1034 + } 1035 + }, 1036 + "social.clippr.actor.defs.preferences": { 1037 + "type": "array", 1038 + "items": { 1039 + "oneOf": [ 1040 + { 1041 + "$ref": "#/components/schemas/social.clippr.actor.defs.publishingScopesPref" 1042 + } 1043 + ] 1044 + } 1045 + }, 1046 + "social.clippr.actor.defs.publishingScopesPref": { 1047 + "type": "object", 1048 + "description": "Preferences for a user's publishing scopes", 1049 + "required": ["defaultScope"], 1050 + "properties": { 1051 + "defaultScope": { 1052 + "type": "string", 1053 + "description": "What publishing scope to mark a clip as by default", 1054 + "enum": ["public", "unlisted"] 1055 + } 1056 + } 1057 + }, 1058 + "social.clippr.feed.defs.clipView": { 1059 + "type": "object", 1060 + "description": "A view of a single bookmark (or 'clip')", 1061 + "required": ["uri", "cid", "author", "record", "indexedAt"], 1062 + "properties": { 1063 + "uri": { 1064 + "type": "string", 1065 + "description": "The AT-URI of the clip", 1066 + "format": "at-uri" 1067 + }, 1068 + "cid": { 1069 + "type": "string", 1070 + "description": "The CID of the clip", 1071 + "format": "cid" 1072 + }, 1073 + "author": { 1074 + "description": "A reference to the actor's profile", 1075 + "$ref": "#/components/schemas/social.clippr.actor.defs.profileView" 1076 + }, 1077 + "record": { 1078 + "type": "object", 1079 + "description": "The raw record of the clip" 1080 + }, 1081 + "indexedAt": { 1082 + "type": "string", 1083 + "description": "The time in which the clip's record was indexed by the AppView", 1084 + "format": "date-time" 1085 + } 1086 + } 1087 + }, 1088 + "social.clippr.feed.defs.tagView": { 1089 + "type": "object", 1090 + "description": "A view of a single tag", 1091 + "required": ["uri", "cid", "author", "record", "indexedAt"], 1092 + "properties": { 1093 + "uri": { 1094 + "type": "string", 1095 + "description": "The AT-URI to the tag", 1096 + "format": "at-uri" 1097 + }, 1098 + "cid": { 1099 + "type": "string", 1100 + "description": "The CID of the tag", 1101 + "format": "cid" 1102 + }, 1103 + "author": { 1104 + "description": "A reference to the actor's profile", 1105 + "$ref": "#/components/schemas/social.clippr.actor.defs.profileView" 1106 + }, 1107 + "record": { 1108 + "type": "object", 1109 + "description": "The raw record of the clip" 1110 + }, 1111 + "indexedAt": { 1112 + "type": "string", 1113 + "description": "The time in which the tag's record was indexed by the AppView", 1114 + "format": "date-time" 1115 + } 1116 + } 1117 + }, 1118 + "social.clippr.actor.profile": { 1119 + "type": "object", 1120 + "required": ["createdAt", "displayName"], 1121 + "properties": { 1122 + "displayName": { 1123 + "type": "string", 1124 + "description": "A display name to be shown on a profile", 1125 + "maxLength": 64 1126 + }, 1127 + "description": { 1128 + "type": "string", 1129 + "description": "Text for user to describe themselves", 1130 + "maxLength": 500 1131 + }, 1132 + "avatar": { 1133 + "type": "blob", 1134 + "maxSize": 1000000, 1135 + "description": "Image to show on user's profiles" 1136 + }, 1137 + "createdAt": { 1138 + "type": "string", 1139 + "description": "The creation date of the profile", 1140 + "format": "date-time" 1141 + } 1142 + } 1143 + }, 1144 + "social.clippr.feed.clip": { 1145 + "type": "object", 1146 + "required": ["url", "title", "description", "unlisted", "createdAt"], 1147 + "properties": { 1148 + "url": { 1149 + "type": "string", 1150 + "description": "The URL of the bookmark. Cannot be left empty or be modified after creation.", 1151 + "format": "uri", 1152 + "maxLength": 2000 1153 + }, 1154 + "title": { 1155 + "type": "string", 1156 + "description": "The title of the bookmark. If left empty, reuse the URL.", 1157 + "maxLength": 2048 1158 + }, 1159 + "description": { 1160 + "type": "string", 1161 + "description": "A description of the bookmark's content. This should be ripped from the URL metadata and be static for all records using the URL.", 1162 + "maxLength": 4096 1163 + }, 1164 + "notes": { 1165 + "type": "string", 1166 + "description": "User-written notes for the bookmark. Public and personal.", 1167 + "maxLength": 10000 1168 + }, 1169 + "tags": { 1170 + "type": "array", 1171 + "description": "An array of tags. A format of solely alphanumeric characters and dashes should be used.", 1172 + "items": { 1173 + "$ref": "#/components/schemas/com.atproto.repo.strongRef" 1174 + } 1175 + }, 1176 + "unlisted": { 1177 + "type": "boolean", 1178 + "description": "Whether the bookmark can be used for feed indexing and aggregation" 1179 + }, 1180 + "unread": { 1181 + "type": "boolean", 1182 + "description": "Whether the bookmark has been read by the user", 1183 + "default": true 1184 + }, 1185 + "languages": { 1186 + "type": "array", 1187 + "items": { 1188 + "type": "string", 1189 + "format": "language" 1190 + }, 1191 + "maxItems": 5 1192 + }, 1193 + "createdAt": { 1194 + "type": "string", 1195 + "description": "Client-declared timestamp when the bookmark is created", 1196 + "format": "date-time" 1197 + } 1198 + } 1199 + }, 1200 + "social.clippr.feed.tag": { 1201 + "type": "object", 1202 + "required": ["name", "createdAt"], 1203 + "properties": { 1204 + "name": { 1205 + "type": "string", 1206 + "description": "A de-duplicated string containing the name of the tag", 1207 + "maxLength": 64 1208 + }, 1209 + "color": { 1210 + "type": "string", 1211 + "description": "A hexadecimal color code", 1212 + "maxLength": 7 1213 + }, 1214 + "description": { 1215 + "type": "string", 1216 + "description": "A description of the tag for additional context", 1217 + "maxLength": 5000 1218 + }, 1219 + "createdAt": { 1220 + "type": "string", 1221 + "description": "A client-defined timestamp for the creation of the tag", 1222 + "format": "date-time" 1223 + } 1224 + } 1225 + } 1226 + } 1227 + } 1292 1228 }