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))
})

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:

Have a recipe to share? Contribute to the Cloudillo documentation.