The recipes.blue monorepo recipes.blue
recipes appview atproto

feat: get profile xrpc endpoint

hayden.moe 7ea96a3e 13adfcbd

verified
+176 -9
+5
apps/api/src/index.ts
··· 5 5 import { logMiddleware } from './logger.js'; 6 6 import pino from 'pino'; 7 7 import { RedisClient } from 'bun'; 8 + import { registerGetProfile } from './xrpc/blue.recipes.actor.getProfile.js'; 8 9 9 10 const logger = pino(); 10 11 const redis = new RedisClient(Bun.env.REDIS_URL ?? "redis://127.0.0.1:6379/0"); ··· 32 33 ], 33 34 }); 34 35 36 + // actor 37 + registerGetProfile(router, logger, redis); 38 + 39 + // feed 35 40 registerGetRecipes(router, logger, redis); 36 41 registerGetRecipe(router, logger, redis); 37 42
+21
apps/api/src/util/cdn.ts
··· 1 + import { type Blob, type LegacyBlob } from "@atcute/lexicons"; 2 + import { isBlob, isLegacyBlob } from "@atcute/lexicons/interfaces"; 3 + 4 + const CDN_ROOT = "https://cdn.bsky.app/img/"; 5 + 6 + export const buildCdnUrl = ( 7 + type: 'feed_thumbnail' | 'post_image' | 'avatar', 8 + did: string, 9 + blob: Blob | LegacyBlob, 10 + ): string => { 11 + let ref: string; 12 + if (isLegacyBlob(blob)) { 13 + ref = blob.cid; 14 + } else if (isBlob(blob)) { 15 + ref = blob.ref.$link; 16 + } else { 17 + throw new Error("Invalid blob type"); 18 + } 19 + 20 + return `${CDN_ROOT}${type}/plain/${did}/${ref}`; 21 + }
+44
apps/api/src/xrpc/blue.recipes.actor.getProfile.ts
··· 1 + import { json, XRPCRouter, XRPCError } from '@atcute/xrpc-server'; 2 + import { BlueRecipesActorGetProfile } from '@cookware/lexicons'; 3 + import { db, eq } from '@cookware/database'; 4 + import { getHandle, parseDid } from '../util/api.js'; 5 + import { Logger } from 'pino'; 6 + import { profilesTable, recipeTable } from '@cookware/database/schema'; 7 + import { RedisClient } from 'bun'; 8 + import { buildCdnUrl } from '../util/cdn.js'; 9 + 10 + export const registerGetProfile = (router: XRPCRouter, _logger: Logger, redis: RedisClient) => { 11 + router.addQuery(BlueRecipesActorGetProfile.mainSchema, { 12 + async handler({ params: { actor } }) { 13 + const where = eq(profilesTable.did, await parseDid(actor)); 14 + const profile = await db.query.profilesTable.findFirst({ 15 + where, 16 + orderBy: profilesTable.createdAt, 17 + extras: { 18 + recipesCount: db.$count(recipeTable, where).as('recipesCount'), 19 + } 20 + }); 21 + 22 + if (!profile) { 23 + throw new XRPCError({ 24 + status: 404, 25 + error: 'ProfileNotFound', 26 + description: `Profile for actor ${actor} not found.`, 27 + }); 28 + } 29 + 30 + return json({ 31 + did: profile.did, 32 + handle: await getHandle(profile.did, redis), 33 + displayName: profile.displayName ?? undefined, 34 + description: profile.description ?? undefined, 35 + pronouns: profile.pronouns ?? undefined, 36 + website: profile.website ?? undefined, 37 + avatar: profile.avatarRef ? buildCdnUrl('avatar', profile.did, profile.avatarRef) : undefined, 38 + banner: profile.avatarRef ? buildCdnUrl('avatar', profile.did, profile.avatarRef) : undefined, 39 + recipesCount: profile.recipesCount, 40 + createdAt: profile.createdAt.toISOString(), 41 + }); 42 + }, 43 + }); 44 + };
+23 -2
libs/lexicons/lexicons/profiles/defs.tsp
··· 10 10 displayName?: string; 11 11 12 12 pronouns?: string; 13 + avatar?: uri; 14 + 15 + @format("datetime") 16 + createdAt?: string; 17 + } 18 + 19 + model ProfileViewDetailed { 20 + @required did: did; 21 + @required handle: handle; 22 + 23 + @maxGraphemes(64) 24 + @maxLength(640) 25 + displayName?: string; 13 26 14 - /** Small image to be displayed on the profile. */ 15 - avatar?: Blob<#["image/png", "image/jpeg"], 1000000>; // 1mb image 27 + @maxGraphemes(256) 28 + @maxLength(2500) 29 + description?: string; 30 + 31 + pronouns?: string; 32 + website?: url; 33 + avatar?: url; 34 + banner?: url; 35 + 36 + recipesCount?: integer; 16 37 17 38 @format("datetime") 18 39 createdAt?: string;
+9
libs/lexicons/lexicons/profiles/getProfile.tsp
··· 1 + import "@typelex/emitter"; 2 + import "./defs.tsp"; 3 + 4 + namespace blue.recipes.actor.getProfile { 5 + @query() 6 + op Main( 7 + @required actor: atIdentifier, 8 + ): blue.recipes.actor.defs.ProfileViewDetailed; 9 + }
+1
libs/lexicons/lexicons/profiles/main.tsp
··· 1 1 import "./profile.tsp"; 2 2 import "./defs.tsp"; 3 + import "./getProfile.tsp";
+1 -2
libs/lexicons/lexicons/profiles/profile.tsp
··· 17 17 @maxLength(200) 18 18 pronouns?: string; 19 19 20 - @format("url") 21 - website?: string; 20 + website?: uri; 22 21 23 22 /** Small image to be displayed on the profile. */ 24 23 avatar?: Blob<#["image/png", "image/jpeg"], 1000000>; // 1mb image
+1
libs/lexicons/lib/index.ts
··· 1 1 export * as BlueRecipesActorDefs from "./types/blue/recipes/actor/defs.js"; 2 + export * as BlueRecipesActorGetProfile from "./types/blue/recipes/actor/getProfile.js"; 2 3 export * as BlueRecipesActorProfile from "./types/blue/recipes/actor/profile.js"; 3 4 export * as BlueRecipesFeedDefs from "./types/blue/recipes/feed/defs.js"; 4 5 export * as BlueRecipesFeedGetRecipe from "./types/blue/recipes/feed/getRecipe.js";
+39 -4
libs/lexicons/lib/types/blue/recipes/actor/defs.ts
··· 5 5 $type: /*#__PURE__*/ v.optional( 6 6 /*#__PURE__*/ v.literal("blue.recipes.actor.defs#profileViewBasic"), 7 7 ), 8 + avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), 9 + createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 10 + did: /*#__PURE__*/ v.didString(), 8 11 /** 9 - * Small image to be displayed on the profile. 10 - * @accept image/png, image/jpeg 11 - * @maxSize 1000000 12 + * @maxLength 640 13 + * @maxGraphemes 64 12 14 */ 13 - avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.blob()), 15 + displayName: /*#__PURE__*/ v.optional( 16 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 17 + /*#__PURE__*/ v.stringLength(0, 640), 18 + /*#__PURE__*/ v.stringGraphemes(0, 64), 19 + ]), 20 + ), 21 + handle: /*#__PURE__*/ v.handleString(), 22 + pronouns: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 23 + }); 24 + const _profileViewDetailedSchema = /*#__PURE__*/ v.object({ 25 + $type: /*#__PURE__*/ v.optional( 26 + /*#__PURE__*/ v.literal("blue.recipes.actor.defs#profileViewDetailed"), 27 + ), 28 + avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 29 + banner: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 14 30 createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 31 + /** 32 + * @maxLength 2500 33 + * @maxGraphemes 256 34 + */ 35 + description: /*#__PURE__*/ v.optional( 36 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 37 + /*#__PURE__*/ v.stringLength(0, 2500), 38 + /*#__PURE__*/ v.stringGraphemes(0, 256), 39 + ]), 40 + ), 15 41 did: /*#__PURE__*/ v.didString(), 16 42 /** 17 43 * @maxLength 640 ··· 25 51 ), 26 52 handle: /*#__PURE__*/ v.handleString(), 27 53 pronouns: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 54 + recipesCount: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 55 + website: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 28 56 }); 29 57 30 58 type profileViewBasic$schematype = typeof _profileViewBasicSchema; 59 + type profileViewDetailed$schematype = typeof _profileViewDetailedSchema; 31 60 32 61 export interface profileViewBasicSchema extends profileViewBasic$schematype {} 62 + export interface profileViewDetailedSchema 63 + extends profileViewDetailed$schematype {} 33 64 34 65 export const profileViewBasicSchema = 35 66 _profileViewBasicSchema as profileViewBasicSchema; 67 + export const profileViewDetailedSchema = 68 + _profileViewDetailedSchema as profileViewDetailedSchema; 36 69 37 70 export interface ProfileViewBasic 38 71 extends v.InferInput<typeof profileViewBasicSchema> {} 72 + export interface ProfileViewDetailed 73 + extends v.InferInput<typeof profileViewDetailedSchema> {}
+31
libs/lexicons/lib/types/blue/recipes/actor/getProfile.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as BlueRecipesActorDefs from "./defs.js"; 5 + 6 + const _mainSchema = /*#__PURE__*/ v.query("blue.recipes.actor.getProfile", { 7 + params: /*#__PURE__*/ v.object({ 8 + actor: /*#__PURE__*/ v.actorIdentifierString(), 9 + }), 10 + output: { 11 + type: "lex", 12 + get schema() { 13 + return BlueRecipesActorDefs.profileViewDetailedSchema; 14 + }, 15 + }, 16 + }); 17 + 18 + type main$schematype = typeof _mainSchema; 19 + 20 + export interface mainSchema extends main$schematype {} 21 + 22 + export const mainSchema = _mainSchema as mainSchema; 23 + 24 + export interface $params extends v.InferInput<mainSchema["params"]> {} 25 + export type $output = v.InferXRPCBodyInput<mainSchema["output"]>; 26 + 27 + declare module "@atcute/lexicons/ambient" { 28 + interface XRPCQueries { 29 + "blue.recipes.actor.getProfile": mainSchema; 30 + } 31 + }
+1 -1
libs/lexicons/lib/types/blue/recipes/actor/profile.ts
··· 49 49 /*#__PURE__*/ v.stringGraphemes(0, 20), 50 50 ]), 51 51 ), 52 - website: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 52 + website: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), 53 53 }), 54 54 ); 55 55