API Cookbook
API Cookbook
Practical recipes for common tasks with the Cloudillo API. Copy, paste, and adapt these examples for your application.
Table of Contents
User Management
Recipe: Complete Registration Flow
Handle user registration with email verification.
async function registerUser(idTag, password, name) {
// Step 1: Initiate registration
const registerRes = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idTag, password, name })
})
if (!registerRes.ok) {
const error = await registerRes.json()
throw new Error(error.error.message)
}
const { data } = await registerRes.json()
console.log('Registration initiated. Check email for verification code.')
// Step 2: User enters verification code from email
const verificationCode = await promptUserForCode()
// Step 3: Verify and complete registration
const verifyRes = await fetch('/api/auth/register-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
idTag,
code: verificationCode
})
})
if (!verifyRes.ok) {
const error = await verifyRes.json()
throw new Error(error.error.message)
}
const { data: verifiedData } = await verifyRes.json()
// Store token
localStorage.setItem('cloudillo_token', verifiedData.token)
return verifiedData
}
// Usage
try {
const user = await registerUser(
'alice@example.com',
'SecurePass123!',
'Alice Johnson'
)
console.log('Welcome,', user.name)
} catch (error) {
console.error('Registration failed:', error.message)
}Recipe: Persistent Login
Keep users logged in across sessions.
class AuthManager {
constructor() {
this.token = null
this.user = null
}
async login(idTag, password) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idTag, password })
})
if (!response.ok) {
throw new Error('Login failed')
}
const { data } = await response.json()
this.token = data.token
this.user = data
// Persist to localStorage
localStorage.setItem('cloudillo_token', data.token)
localStorage.setItem('cloudillo_user', JSON.stringify(data))
return data
}
async restoreSession() {
const token = localStorage.getItem('cloudillo_token')
const userStr = localStorage.getItem('cloudillo_user')
if (!token || !userStr) {
return null
}
this.token = token
this.user = JSON.parse(userStr)
// Verify token is still valid
try {
const response = await fetch('/api/me', {
headers: { 'Authorization': `Bearer ${token}` }
})
if (!response.ok) {
// Token invalid, clear session
this.logout()
return null
}
const { data } = await response.json()
this.user = data
return data
} catch (error) {
this.logout()
return null
}
}
async refreshToken() {
if (!this.token) {
throw new Error('No token to refresh')
}
const response = await fetch('/api/auth/login-token', {
headers: { 'Authorization': `Bearer ${this.token}` }
})
if (!response.ok) {
throw new Error('Token refresh failed')
}
const { data } = await response.json()
this.token = data.token
localStorage.setItem('cloudillo_token', data.token)
return data.token
}
logout() {
this.token = null
this.user = null
localStorage.removeItem('cloudillo_token')
localStorage.removeItem('cloudillo_user')
}
isAuthenticated() {
return !!this.token
}
getAuthHeader() {
return { 'Authorization': `Bearer ${this.token}` }
}
}
// Usage
const auth = new AuthManager()
// On app load
await auth.restoreSession()
if (auth.isAuthenticated()) {
console.log('Welcome back,', auth.user.name)
} else {
// Show login form
await auth.login('alice@example.com', 'password')
}
// Make authenticated requests
fetch('/api/action', {
headers: auth.getAuthHeader()
})Social Features
Recipe: Build a Social Feed
Create a timeline of posts from followed users.
class SocialFeed {
constructor(api) {
this.api = api
this.posts = []
this.offset = 0
this.limit = 20
}
async loadInitial() {
this.offset = 0
this.posts = []
const response = await this.api.action.get({
type: 'POST',
status: 'A',
_limit: this.limit,
_offset: this.offset,
_expand: 'issuer',
_sort: 'createdAt',
_order: 'desc'
})
this.posts = response.data
this.hasMore = response.pagination.hasMore
return this.posts
}
async loadMore() {
if (!this.hasMore) {
return []
}
this.offset += this.limit
const response = await this.api.action.get({
type: 'POST',
status: 'A',
_limit: this.limit,
_offset: this.offset,
_expand: 'issuer',
_sort: 'createdAt',
_order: 'desc'
})
this.posts.push(...response.data)
this.hasMore = response.pagination.hasMore
return response.data
}
async refresh() {
// Get only new posts since last load
const latestPost = this.posts[0]
const createdAfter = latestPost ? latestPost.createdAt : 0
const response = await this.api.action.get({
type: 'POST',
status: 'A',
createdAfter,
_expand: 'issuer',
_sort: 'createdAt',
_order: 'desc'
})
// Prepend new posts
this.posts = [...response.data, ...this.posts]
return response.data
}
getPost(actionId) {
return this.posts.find(p => p.actionId === actionId)
}
updatePost(actionId, updates) {
const index = this.posts.findIndex(p => p.actionId === actionId)
if (index !== -1) {
this.posts[index] = { ...this.posts[index], ...updates }
}
}
}
// Usage
const feed = new SocialFeed(api)
// Initial load
const posts = await feed.loadInitial()
renderPosts(posts)
// Infinite scroll
document.addEventListener('scroll', async () => {
if (isNearBottom() && feed.hasMore) {
const morePosts = await feed.loadMore()
renderPosts(morePosts)
}
})
// Pull to refresh
async function handleRefresh() {
const newPosts = await feed.refresh()
if (newPosts.length > 0) {
showNotification(`${newPosts.length} new posts`)
renderPosts(feed.posts)
}
}Recipe: Comment Thread with Nesting
Display nested comments with replies.
async function loadCommentThread(rootActionId) {
// Get all comments in the thread
const response = await api.action.get({
rootId: rootActionId,
type: 'CMNT',
_expand: 'issuer',
_sort: 'createdAt',
_order: 'asc',
_limit: 200
})
const comments = response.data
// Build tree structure
const commentMap = new Map()
const rootComments = []
// First pass: create map
comments.forEach(comment => {
commentMap.set(comment.actionId, {
...comment,
replies: []
})
})
// Second pass: build tree
comments.forEach(comment => {
const node = commentMap.get(comment.actionId)
if (comment.parentId === rootActionId) {
// Top-level comment
rootComments.push(node)
} else {
// Reply to another comment
const parent = commentMap.get(comment.parentId)
if (parent) {
parent.replies.push(node)
}
}
})
return rootComments
}
function renderComment(comment, depth = 0) {
const indent = ' '.repeat(depth)
console.log(`${indent}${comment.issuer.name}: ${comment.content.text}`)
comment.replies.forEach(reply => {
renderComment(reply, depth + 1)
})
}
// Usage
const thread = await loadCommentThread('act_post123')
console.log('Comment Thread:')
thread.forEach(comment => renderComment(comment))
// Example output:
// Alice: Great post!
// Bob: Thanks Alice!
// Alice: You're welcome!
// Carol: I agree with Alice
// Dave: Interesting perspective
Recipe: Like/Unlike Toggle
Implement a like button that toggles.
class LikeButton {
constructor(actionId, api) {
this.actionId = actionId
this.api = api
this.isLiked = false
this.likeActionId = null
this.count = 0
}
async initialize() {
// Get current state
const action = await this.api.action.id(this.actionId).get()
this.count = action.data.stat?.reactions || 0
this.isLiked = !!action.data.stat?.ownReaction
this.likeActionId = action.data.stat?.ownReactionId
return this.isLiked
}
async toggle() {
if (this.isLiked) {
return this.unlike()
} else {
return this.like()
}
}
async like() {
const reaction = await this.api.action.id(this.actionId).reaction.post({
type: 'LOVE'
})
this.isLiked = true
this.likeActionId = reaction.data.actionId
this.count++
return true
}
async unlike() {
if (!this.likeActionId) {
return false
}
await this.api.action.id(this.likeActionId).delete()
this.isLiked = false
this.likeActionId = null
this.count = Math.max(0, this.count - 1)
return false
}
getState() {
return {
isLiked: this.isLiked,
count: this.count
}
}
}
// Usage with UI
const likeBtn = new LikeButton('act_post123', api)
await likeBtn.initialize()
document.getElementById('like-button').addEventListener('click', async () => {
const button = document.getElementById('like-button')
button.disabled = true
try {
await likeBtn.toggle()
const { isLiked, count } = likeBtn.getState()
button.textContent = isLiked ? '❤️ Liked' : '🤍 Like'
document.getElementById('like-count').textContent = count
} catch (error) {
console.error('Failed to toggle like:', error)
} finally {
button.disabled = false
}
})File Operations
Recipe: Drag & Drop File Upload
Upload files with drag and drop interface.
class FileUploader {
constructor(token) {
this.token = token
this.uploads = []
}
setupDropZone(element) {
element.addEventListener('dragover', (e) => {
e.preventDefault()
element.classList.add('drag-over')
})
element.addEventListener('dragleave', () => {
element.classList.remove('drag-over')
})
element.addEventListener('drop', async (e) => {
e.preventDefault()
element.classList.remove('drag-over')
const files = Array.from(e.dataTransfer.files)
await this.uploadFiles(files)
})
}
async uploadFiles(files) {
const uploads = files.map(file => this.uploadFile(file))
return Promise.all(uploads)
}
async uploadFile(file, preset = 'default', tags = '') {
const uploadId = Date.now() + Math.random()
const upload = {
id: uploadId,
file,
status: 'uploading',
progress: 0,
fileId: null,
error: null
}
this.uploads.push(upload)
this.onUploadStart(upload)
try {
const result = await this.uploadWithProgress(
file,
preset,
tags,
(progress) => {
upload.progress = progress
this.onUploadProgress(upload)
}
)
upload.status = 'complete'
upload.fileId = result.data.fileId
this.onUploadComplete(upload)
return result.data
} catch (error) {
upload.status = 'error'
upload.error = error.message
this.onUploadError(upload)
throw error
}
}
async uploadWithProgress(file, preset, tags, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress((e.loaded / e.total) * 100)
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText))
} else {
reject(new Error('Upload failed'))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Network error'))
})
const url = `/api/file/${preset}/${encodeURIComponent(file.name)}${
tags ? '?tags=' + encodeURIComponent(tags) : ''
}`
xhr.open('POST', url)
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
xhr.setRequestHeader('Content-Type', file.type)
xhr.send(file)
})
}
// Override these methods to handle events
onUploadStart(upload) {
console.log('Upload started:', upload.file.name)
}
onUploadProgress(upload) {
console.log(`Upload progress: ${upload.progress.toFixed(1)}%`)
}
onUploadComplete(upload) {
console.log('Upload complete:', upload.fileId)
}
onUploadError(upload) {
console.error('Upload error:', upload.error)
}
}
// Usage
const uploader = new FileUploader(token)
// Custom event handlers
uploader.onUploadProgress = (upload) => {
document.getElementById(`progress-${upload.id}`).value = upload.progress
}
uploader.onUploadComplete = (upload) => {
showNotification(`✓ ${upload.file.name} uploaded!`)
}
// Setup drop zone
const dropZone = document.getElementById('drop-zone')
uploader.setupDropZone(dropZone)
// Also handle file input
document.getElementById('file-input').addEventListener('change', (e) => {
uploader.uploadFiles(Array.from(e.target.files))
})Recipe: Image Gallery with Lazy Loading
Display images with appropriate variants.
class ImageGallery {
constructor(api) {
this.api = api
this.images = []
this.observer = null
}
async loadImages() {
const response = await this.api.file.get({
fileTp: 'BLOB',
contentType: 'image/*',
_limit: 100,
_sort: 'createdAt',
_order: 'desc'
})
this.images = response.data
return this.images
}
setupLazyLoading() {
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target
this.loadImage(img)
this.observer.unobserve(img)
}
})
}, {
rootMargin: '50px'
})
}
loadImage(img) {
const fileId = img.dataset.fileId
const variant = img.dataset.variant || 'sd'
img.src = `/api/file/${fileId}?variant=${variant}`
img.onload = () => {
img.classList.add('loaded')
}
img.onerror = () => {
img.src = '/placeholder.png'
}
}
renderGallery(container) {
container.innerHTML = ''
this.images.forEach(image => {
const figure = document.createElement('figure')
figure.className = 'gallery-item'
const img = document.createElement('img')
img.dataset.fileId = image.fileId
img.dataset.variant = 'tn' // Use thumbnail initially
img.alt = image.fileName
img.loading = 'lazy'
// Observe for lazy loading
this.observer.observe(img)
const caption = document.createElement('figcaption')
caption.textContent = image.fileName
figure.appendChild(img)
figure.appendChild(caption)
container.appendChild(figure)
// Click to view full size
figure.addEventListener('click', () => {
this.showLightbox(image)
})
})
}
showLightbox(image) {
const lightbox = document.createElement('div')
lightbox.className = 'lightbox'
const img = document.createElement('img')
img.src = `/api/file/${image.fileId}?variant=hd`
img.alt = image.fileName
lightbox.appendChild(img)
lightbox.addEventListener('click', () => {
document.body.removeChild(lightbox)
})
document.body.appendChild(lightbox)
}
}
// Usage
const gallery = new ImageGallery(api)
gallery.setupLazyLoading()
const images = await gallery.loadImages()
gallery.renderGallery(document.getElementById('gallery'))Real-time Features
Recipe: Live Notifications
Show real-time notifications for new actions.
class NotificationManager {
constructor(api, serverUrl) {
this.api = api
this.serverUrl = serverUrl
this.ws = null
this.notifications = []
this.listeners = []
}
connect() {
const wsUrl = this.serverUrl.replace('http', 'ws') + '/ws/bus'
this.ws = new WebSocket(wsUrl)
this.ws.onopen = () => {
console.log('✓ Connected to notification stream')
// Subscribe to action events
this.ws.send(JSON.stringify({
type: 'subscribe',
channels: ['actions', 'messages']
}))
}
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data)
this.handleMessage(data)
}
this.ws.onerror = (error) => {
console.error('WebSocket error:', error)
}
this.ws.onclose = () => {
console.log('Disconnected from notification stream')
// Reconnect after delay
setTimeout(() => this.connect(), 5000)
}
}
disconnect() {
if (this.ws) {
this.ws.close()
this.ws = null
}
}
handleMessage(data) {
switch (data.type) {
case 'action':
this.handleNewAction(data.action)
break
case 'message':
this.handleNewMessage(data.message)
break
}
}
handleNewAction(action) {
// Filter for relevant actions
if (action.type === 'CMNT' && action.parentId) {
// Someone commented on your post
this.notify({
type: 'comment',
title: 'New Comment',
message: `${action.issuer.name} commented on your post`,
action
})
}
if (action.type === 'REACT' && action.parentId) {
// Someone reacted to your content
this.notify({
type: 'reaction',
title: 'New Reaction',
message: `${action.issuer.name} reacted to your post`,
action
})
}
if (action.type === 'FLLW' && action.subject === this.api.idTag) {
// Someone followed you
this.notify({
type: 'follow',
title: 'New Follower',
message: `${action.issuer.name} started following you`,
action
})
}
}
handleNewMessage(message) {
if (message.subject === this.api.idTag) {
// New message for you
this.notify({
type: 'message',
title: 'New Message',
message: `${message.issuer.name}: ${message.content.text}`,
action: message
})
}
}
notify(notification) {
this.notifications.unshift(notification)
// Show browser notification
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(notification.title, {
body: notification.message,
icon: '/icon.png'
})
}
// Trigger listeners
this.listeners.forEach(listener => listener(notification))
}
onNotification(callback) {
this.listeners.push(callback)
}
getNotifications(limit = 10) {
return this.notifications.slice(0, limit)
}
clearNotifications() {
this.notifications = []
}
}
// Usage
const notifications = new NotificationManager(api, 'https://your-server.com')
notifications.connect()
// Listen for notifications
notifications.onNotification((notification) => {
showToast(notification.message)
playSound()
updateBadgeCount()
})
// Request browser notification permission
if ('Notification' in window) {
Notification.requestPermission()
}Advanced Patterns
Recipe: Offline Queue with Sync
Queue actions when offline and sync when online.
class OfflineQueue {
constructor(api) {
this.api = api
this.queue = this.loadQueue()
this.syncing = false
// Listen for online/offline events
window.addEventListener('online', () => this.sync())
window.addEventListener('offline', () => {
console.log('Offline mode activated')
})
}
loadQueue() {
const stored = localStorage.getItem('offline_queue')
return stored ? JSON.parse(stored) : []
}
saveQueue() {
localStorage.setItem('offline_queue', JSON.stringify(this.queue))
}
async createAction(actionData) {
if (!navigator.onLine) {
// Offline: queue for later
const queuedAction = {
id: 'temp_' + Date.now(),
data: actionData,
timestamp: Date.now(),
status: 'queued'
}
this.queue.push(queuedAction)
this.saveQueue()
console.log('Action queued for when online')
return queuedAction
}
// Online: post immediately
try {
const result = await this.api.action.post(actionData)
return result.data
} catch (error) {
// Failed: queue for retry
const queuedAction = {
id: 'temp_' + Date.now(),
data: actionData,
timestamp: Date.now(),
status: 'queued',
error: error.message
}
this.queue.push(queuedAction)
this.saveQueue()
throw error
}
}
async sync() {
if (this.syncing || this.queue.length === 0) {
return
}
this.syncing = true
console.log(`Syncing ${this.queue.length} queued actions...`)
const results = []
for (const item of this.queue) {
try {
const result = await this.api.action.post(item.data)
results.push({
tempId: item.id,
actionId: result.data.actionId,
status: 'success'
})
// Remove from queue
this.queue = this.queue.filter(q => q.id !== item.id)
} catch (error) {
results.push({
tempId: item.id,
status: 'failed',
error: error.message
})
// Keep in queue for retry
item.retryCount = (item.retryCount || 0) + 1
}
}
this.saveQueue()
this.syncing = false
console.log('Sync complete:', results)
return results
}
getQueueStatus() {
return {
count: this.queue.length,
items: this.queue
}
}
}
// Usage
const queue = new OfflineQueue(api)
// Create actions (works online or offline)
async function createPost(content) {
try {
const action = await queue.createAction({
type: 'POST',
content
})
if (action.id.startsWith('temp_')) {
showMessage('Post queued. Will send when online.')
} else {
showMessage('Post published!')
}
return action
} catch (error) {
showError('Failed to post. Will retry when online.')
}
}
// Manual sync
document.getElementById('sync-button').addEventListener('click', async () => {
await queue.sync()
showMessage('Sync complete!')
})Recipe: Optimistic UI Updates
Update UI immediately, rollback on failure.
class OptimisticUpdater {
constructor(api) {
this.api = api
this.pendingUpdates = new Map()
}
async createPost(content, onUpdate) {
const tempId = 'temp_' + Date.now()
// Optimistic post
const optimisticPost = {
actionId: tempId,
type: 'POST',
content,
issuerTag: this.api.idTag,
createdAt: Math.floor(Date.now() / 1000),
status: 'P', // Pending
_optimistic: true
}
// Update UI immediately
onUpdate('add', optimisticPost)
this.pendingUpdates.set(tempId, optimisticPost)
try {
// Create for real
const result = await this.api.action.post({
type: 'POST',
content
})
// Replace optimistic with real
this.pendingUpdates.delete(tempId)
onUpdate('replace', tempId, result.data)
return result.data
} catch (error) {
// Remove optimistic on failure
this.pendingUpdates.delete(tempId)
onUpdate('remove', tempId)
throw error
}
}
async addReaction(actionId, reactionType, onUpdate) {
const tempReactionId = 'temp_reaction_' + Date.now()
// Optimistically update count
onUpdate('increment', actionId, 'reactions')
try {
const result = await this.api.action.id(actionId).reaction.post({
type: reactionType
})
return result.data
} catch (error) {
// Rollback count
onUpdate('decrement', actionId, 'reactions')
throw error
}
}
}
// Usage with React
function PostList() {
const [posts, setPosts] = useState([])
const updater = new OptimisticUpdater(api)
const handleUpdate = (action, ...args) => {
switch (action) {
case 'add':
setPosts([args[0], ...posts])
break
case 'replace':
setPosts(posts.map(p =>
p.actionId === args[0] ? args[1] : p
))
break
case 'remove':
setPosts(posts.filter(p => p.actionId !== args[0]))
break
case 'increment':
setPosts(posts.map(p =>
p.actionId === args[0]
? { ...p, stat: { ...p.stat, [args[1]]: (p.stat[args[1]] || 0) + 1 } }
: p
))
break
case 'decrement':
setPosts(posts.map(p =>
p.actionId === args[0]
? { ...p, stat: { ...p.stat, [args[1]]: Math.max(0, (p.stat[args[1]] || 0) - 1) } }
: p
))
break
}
}
const createPost = async (content) => {
try {
await updater.createPost(content, handleUpdate)
showToast('Post published!')
} catch (error) {
showToast('Failed to publish post')
}
}
return (
<div>
{posts.map(post => (
<Post
key={post.actionId}
post={post}
isPending={post._optimistic}
/>
))}
</div>
)
}Next Steps
Explore more recipes:
- REST API Reference - Full API documentation
- Quick Start - Getting started guide
- Error Handling - Handle errors gracefully
Have a recipe to share? Contribute to the Cloudillo documentation.