Files
metabuilder/docs/guides/api-development.md

13 KiB

API Development Guide

Guide for building and consuming APIs in MetaBuilder.

API Structure

MetaBuilder uses Next.js API routes for backend endpoints:

app/api/
├── users/
│   ├── route.ts           # GET /api/users, POST /api/users
│   └── [id]/
│       └── route.ts       # GET /api/users/[id], PATCH, DELETE
├── workflows/
│   ├── route.ts
│   └── [id]/
│       └── route.ts
└── auth/
    ├── login/route.ts
    ├── register/route.ts
    ├── session/route.ts
    └── logout/route.ts

Users API (DBAL-backed)

The users endpoints run on the server and use DBAL helpers + credential storage.

Create User

POST /api/users
Content-Type: application/json

{
  "username": "newuser",
  "email": "newuser@example.com",
  "role": "admin",
  "password": "s3cret",
  "bio": "Short bio",
  "profilePicture": "https://example.com/avatar.png"
}

Response:

{
  "user": {
    "id": "user_123",
    "username": "newuser",
    "email": "newuser@example.com",
    "role": "admin",
    "bio": "Short bio",
    "profilePicture": "https://example.com/avatar.png",
    "createdAt": 1715612345678
  }
}

Update User

PATCH /api/users/{id}
Content-Type: application/json

{
  "email": "updated@example.com",
  "password": "new-secret"
}

Delete User

DELETE /api/users/{id}

Auth API (Session-backed)

Authentication uses a database-backed session with an httpOnly cookie (mb_session). Sessions default to a 7 day TTL and are refreshed on /api/auth/session.

Notes:

  • Seeded accounts set firstLogin=true to force an immediate password change on first sign-in.
  • Updating credentials clears firstLogin and updates passwordChangeTimestamp on the user.

Login

POST /api/auth/login
Content-Type: application/json

{
  "identifier": "alice@example.com",
  "password": "s3cret"
}

Response:

{
  "user": {
    "id": "user_123",
    "username": "alice",
    "email": "alice@example.com",
    "role": "user",
    "createdAt": 1715612345678
  }
}

Register

POST /api/auth/register
Content-Type: application/json

{
  "username": "newuser",
  "email": "newuser@example.com",
  "password": "s3cret"
}

Session Check

GET /api/auth/session

Response:

{
  "user": null
}

Logout

POST /api/auth/logout

Screenshot Analysis API

Local heuristic analysis for the Screenshot Analyzer UI.

POST /api/screenshot/analyze
Content-Type: application/json

{
  "title": "Dashboard",
  "url": "https://example.com/dashboard",
  "viewport": { "width": 1280, "height": 720 },
  "textSample": "First 3k characters of text",
  "htmlSample": "First 6k characters of HTML"
}

Response:

{
  "report": "Screenshot Analysis (local heuristics)...",
  "metrics": {
    "wordCount": 120,
    "headingCount": 4,
    "h1Count": 1,
    "h2Count": 2,
    "h3Count": 1,
    "imgCount": 3,
    "imgMissingAltCount": 1,
    "linkCount": 12,
    "buttonCount": 4,
    "formCount": 1,
    "inputCount": 3
  },
  "warnings": [
    "Images missing alt text: 1."
  ]
}

Creating an API Endpoint

1. Create Route File

// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { validateRequest } from '@/lib/auth'
import { prisma } from '@/lib/prisma'

/**
 * GET /api/posts - List user's posts
 * @requires Level 2+
 */
export async function GET(req: NextRequest) {
  try {
    // Validate authentication
    const user = await validateRequest(req)
    
    if (!user) {
      return NextResponse.json(
        { error: 'Unauthorized' },
        { status: 401 }
      )
    }
    
    // Get posts for user's tenant
    const posts = await prisma.post.findMany({
      where: {
        tenantId: user.tenantId,
        author: { level: { gte: 2 } }, // Level 2+
      },
      select: {
        id: true,
        title: true,
        content: true,
        createdAt: true,
      },
      orderBy: { createdAt: 'desc' },
    })
    
    return NextResponse.json(posts)
  } catch (error) {
    console.error('GET /api/posts error:', error)
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

/**
 * POST /api/posts - Create new post
 * @requires Level 2+
 * @body {{ title: string, content: string }}
 */
export async function POST(req: NextRequest) {
  try {
    const user = await validateRequest(req)
    
    if (!user || user.level < 2) {
      return NextResponse.json(
        { error: 'Forbidden' },
        { status: 403 }
      )
    }
    
    const body = await req.json()
    
    // Validate input
    if (!body.title || !body.content) {
      return NextResponse.json(
        { error: 'Title and content required' },
        { status: 400 }
      )
    }
    
    // Create post
    const post = await prisma.post.create({
      data: {
        title: body.title,
        content: body.content,
        authorId: user.id,
        tenantId: user.tenantId,
      },
    })
    
    return NextResponse.json(post, { status: 201 })
  } catch (error) {
    console.error('POST /api/posts error:', error)
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

2. Create Dynamic Route

// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { validateRequest, canAccessLevel } from '@/lib/auth'
import { prisma } from '@/lib/prisma'

interface RouteParams {
  params: {
    id: string
  }
}

/**
 * GET /api/posts/[id] - Get specific post
 */
export async function GET(
  req: NextRequest,
  { params }: RouteParams
) {
  try {
    const user = await validateRequest(req)
    if (!user) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
    }
    
    const post = await prisma.post.findFirst({
      where: {
        id: params.id,
        tenantId: user.tenantId,
      },
      include: { author: true },
    })
    
    if (!post) {
      return NextResponse.json({ error: 'Not found' }, { status: 404 })
    }
    
    return NextResponse.json(post)
  } catch (error) {
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

/**
 * PATCH /api/posts/[id] - Update post
 * @requires Owner or Level 3+
 */
export async function PATCH(
  req: NextRequest,
  { params }: RouteParams
) {
  try {
    const user = await validateRequest(req)
    if (!user) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
    }
    
    // Check ownership or admin
    const post = await prisma.post.findFirst({
      where: { id: params.id, tenantId: user.tenantId },
    })
    
    if (!post) {
      return NextResponse.json({ error: 'Not found' }, { status: 404 })
    }
    
    const isOwner = post.authorId === user.id
    const isAdmin = canAccessLevel(user.level, 3)
    
    if (!isOwner && !isAdmin) {
      return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
    }
    
    const body = await req.json()
    
    const updated = await prisma.post.update({
      where: { id: params.id },
      data: {
        title: body.title,
        content: body.content,
      },
    })
    
    return NextResponse.json(updated)
  } catch (error) {
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

/**
 * DELETE /api/posts/[id] - Delete post
 * @requires Owner or Level 3+
 */
export async function DELETE(
  req: NextRequest,
  { params }: RouteParams
) {
  try {
    const user = await validateRequest(req)
    if (!user) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
    }
    
    const post = await prisma.post.findFirst({
      where: { id: params.id, tenantId: user.tenantId },
    })
    
    if (!post) {
      return NextResponse.json({ error: 'Not found' }, { status: 404 })
    }
    
    if (post.authorId !== user.id && !canAccessLevel(user.level, 3)) {
      return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
    }
    
    await prisma.post.delete({ where: { id: params.id } })
    
    return NextResponse.json({ success: true })
  } catch (error) {
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

Consuming APIs

From React Components

import { useEffect, useState } from 'react'

export const PostList = () => {
  const [posts, setPosts] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)
  
  useEffect(() => {
    // Fetch posts
    const fetchPosts = async () => {
      try {
        const response = await fetch('/api/posts')
        
        if (!response.ok) {
          throw new Error(`API error: ${response.status}`)
        }
        
        const data = await response.json()
        setPosts(data)
      } catch (err) {
        setError(err instanceof Error ? err.message : 'Unknown error')
      } finally {
        setLoading(false)
      }
    }
    
    fetchPosts()
  }, [])
  
  if (loading) return <div>Loading...</div>
  if (error) return <div>Error: {error}</div>
  
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

Using Custom Hook

// hooks/useFetch.ts
import { useState, useEffect } from 'react'

export const useFetch = <T,>(url: string) => {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)
  
  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(url)
        if (!response.ok) throw new Error('API error')
        const json = await response.json()
        setData(json)
      } catch (err) {
        setError(err instanceof Error ? err : new Error('Unknown error'))
      } finally {
        setLoading(false)
      }
    }
    
    fetchData()
  }, [url])
  
  return { data, loading, error }
}

// Usage
const { data: posts } = useFetch('/api/posts')

Error Handling

Common Status Codes

Code Meaning Handling
200 Success Process data
201 Created Show success message
400 Bad Request Validate input
401 Unauthorized Redirect to login
403 Forbidden Show permission error
404 Not Found Show not found message
500 Server Error Show error, log issue

Consistent Error Response

export interface ApiError {
  error: string
  code?: string
  details?: Record<string, string>
}

// Return error
return NextResponse.json(
  {
    error: 'Validation failed',
    code: 'INVALID_INPUT',
    details: {
      title: 'Title is required',
      email: 'Email format invalid',
    },
  },
  { status: 400 }
)

Authentication & Authorization

Validate Request

import { validateRequest } from '@/lib/auth'

const user = await validateRequest(req)

if (!user) {
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

Check Permission Level

import { canAccessLevel } from '@/lib/auth'

if (!canAccessLevel(user.level, 3)) {
  return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}

Rate Limiting

// Simple in-memory rate limiter
const rateLimitMap = new Map<string, number[]>()

function checkRateLimit(userId: string, limit: number = 100): boolean {
  const now = Date.now()
  const oneMinuteAgo = now - 60 * 1000
  
  const timestamps = rateLimitMap.get(userId) || []
  const recent = timestamps.filter(t => t > oneMinuteAgo)
  
  if (recent.length >= limit) {
    return false
  }
  
  recent.push(now)
  rateLimitMap.set(userId, recent)
  return true
}

Testing APIs

// __tests__/api/posts.spec.ts
import { GET, POST } from '@/app/api/posts/route'

describe('POST API', () => {
  it('should get posts', async () => {
    const req = new Request('http://localhost/api/posts')
    const response = await GET(req as any)
    
    expect(response.status).toBe(200)
  })
})

Best Practices

Do:

  • Always validate authentication
  • Check permissions on sensitive operations
  • Filter data by tenantId
  • Use TypeScript for request/response types
  • Document endpoints with JSDoc
  • Handle errors consistently
  • Validate input before processing
  • Use appropriate HTTP status codes

Don't:

  • Trust user input without validation
  • Forget tenantId in queries
  • Return sensitive data in error messages
  • Mix multiple resources in one endpoint
  • Skip error handling
  • Use GET for data modification
  • Expose database errors to client

See related guides in /docs/ for more patterns.