Files
Trilium/docs/Developer Guide/Architecture/API-Architecture.md
2025-08-21 15:55:44 +00:00

19 KiB
Vendored

API Architecture

Trilium provides multiple API layers for different use cases: Internal API for frontend-backend communication, ETAPI for external integrations, and WebSocket for real-time synchronization. This document details each API layer's design, usage, and best practices.

API Layers Overview

graph TB
    subgraph "Client Applications"
        WebApp[Web Application]
        Desktop[Desktop App]
        Mobile[Mobile App]
        External[External Apps]
        Scripts[User Scripts]
    end
    
    subgraph "API Layers"
        Internal[Internal API<br/>REST + WebSocket]
        ETAPI[ETAPI<br/>External API]
        WS[WebSocket<br/>Real-time Sync]
    end
    
    subgraph "Backend Services"
        Routes[Route Handlers]
        Services[Business Logic]
        Becca[Becca Cache]
        DB[(Database)]
    end
    
    WebApp --> Internal
    Desktop --> Internal
    Mobile --> Internal
    External --> ETAPI
    Scripts --> ETAPI
    
    Internal --> Routes
    ETAPI --> Routes
    WS --> Services
    
    Routes --> Services
    Services --> Becca
    Becca --> DB
    
    style Internal fill:#e3f2fd
    style ETAPI fill:#fff3e0
    style WS fill:#f3e5f5

Internal API

Location: /apps/server/src/routes/api/

The Internal API handles communication between Trilium's frontend and backend, providing full access to application functionality.

Architecture

// Route structure
/api/
├── notes.ts           // Note operations
├── branches.ts        // Branch management
├── attributes.ts      // Attribute operations
├── tree.ts           // Tree structure
├── search.ts         // Search functionality
├── sync.ts           // Synchronization
├── options.ts        // Configuration
└── special.ts        // Special operations

Request/Response Pattern

// Typical API endpoint structure
router.get('/notes/:noteId', (req, res) => {
    const note = becca.getNote(req.params.noteId);
    
    if (!note) {
        return res.status(404).json({ 
            error: 'Note not found' 
        });
    }
    
    res.json(note.getPojo());
});

router.put('/notes/:noteId', (req, res) => {
    const note = becca.getNoteOrThrow(req.params.noteId);
    
    note.title = req.body.title;
    note.content = req.body.content;
    note.save();
    
    res.json({ success: true });
});

Key Endpoints

Note Operations

// Get note with content
GET /api/notes/:noteId
Response: {
    noteId: string,
    title: string,
    type: string,
    content: string,
    dateCreated: string,
    dateModified: string
}

// Update note
PUT /api/notes/:noteId
Body: {
    title?: string,
    content?: string,
    type?: string,
    mime?: string
}

// Create note
POST /api/notes/:parentNoteId/children
Body: {
    title: string,
    type: string,
    content?: string,
    position?: number
}

// Delete note
DELETE /api/notes/:noteId

Tree Operations

// Get tree structure
GET /api/tree
Query: {
    subTreeNoteId?: string,
    includeAttributes?: boolean
}
Response: {
    notes: FNoteRow[],
    branches: FBranchRow[],
    attributes: FAttributeRow[]
}

// Move branch
PUT /api/branches/:branchId/move
Body: {
    parentNoteId: string,
    position: number
}

Search Operations

// Execute search
GET /api/search
Query: {
    query: string,
    fastSearch?: boolean,
    includeArchivedNotes?: boolean,
    ancestorNoteId?: string
}
Response: {
    results: Array<{
        noteId: string,
        title: string,
        path: string,
        score: number
    }>
}

Authentication & Security

// CSRF protection
app.use(csrfMiddleware);

// Session authentication
router.use((req, res, next) => {
    if (!req.session.loggedIn) {
        return res.status(401).json({ 
            error: 'Not authenticated' 
        });
    }
    next();
});

// Protected note access
router.get('/notes/:noteId', (req, res) => {
    const note = becca.getNote(req.params.noteId);
    
    if (note.isProtected && !protectedSessionService.isProtectedSessionAvailable()) {
        return res.status(403).json({ 
            error: 'Protected session required' 
        });
    }
    
    res.json(note.getPojo());
});

ETAPI (External API)

Location: /apps/server/src/etapi/

ETAPI provides a stable, versioned API for external applications and scripts to interact with Trilium.

Architecture

// ETAPI structure
/etapi/
├── etapi.openapi.yaml    // OpenAPI specification
├── auth.ts               // Authentication
├── notes.ts              // Note endpoints
├── branches.ts           // Branch endpoints
├── attributes.ts         // Attribute endpoints
├── attachments.ts        // Attachment endpoints
└── special_notes.ts      // Special note operations

Authentication

ETAPI uses token-based authentication:

// Creating ETAPI token
POST /etapi/auth/login
Body: {
    username: string,
    password: string
}
Response: {
    authToken: string
}

// Using token in requests
GET /etapi/notes/:noteId
Headers: {
    Authorization: "authToken"
}

Key Endpoints

Note CRUD Operations

// Create note
POST /etapi/notes
Body: {
    noteId?: string,
    parentNoteId: string,
    title: string,
    type: string,
    content?: string,
    position?: number
}

// Get note
GET /etapi/notes/:noteId
Response: {
    noteId: string,
    title: string,
    type: string,
    mime: string,
    isProtected: boolean,
    attributes: Array<{
        attributeId: string,
        type: string,
        name: string,
        value: string
    }>,
    parentNoteIds: string[],
    childNoteIds: string[],
    dateCreated: string,
    dateModified: string
}

// Update note content
PUT /etapi/notes/:noteId/content
Body: string | Buffer
Headers: {
    "Content-Type": mime-type
}

// Delete note
DELETE /etapi/notes/:noteId

Attribute Management

// Create attribute
POST /etapi/attributes
Body: {
    noteId: string,
    type: 'label' | 'relation',
    name: string,
    value: string,
    isInheritable?: boolean
}

// Update attribute
PATCH /etapi/attributes/:attributeId
Body: {
    value?: string,
    isInheritable?: boolean
}
// Search notes
GET /etapi/notes/search
Query: {
    search: string,
    limit?: number,
    orderBy?: string,
    orderDirection?: 'asc' | 'desc'
}
Response: {
    results: Array<{
        noteId: string,
        title: string,
        // Other note properties
    }>
}

Client Libraries

// JavaScript client example
class EtapiClient {
    constructor(serverUrl, authToken) {
        this.serverUrl = serverUrl;
        this.authToken = authToken;
    }
    
    async getNote(noteId) {
        const response = await fetch(
            `${this.serverUrl}/etapi/notes/${noteId}`,
            {
                headers: {
                    'Authorization': this.authToken
                }
            }
        );
        return response.json();
    }
    
    async createNote(parentNoteId, title, content) {
        const response = await fetch(
            `${this.serverUrl}/etapi/notes`,
            {
                method: 'POST',
                headers: {
                    'Authorization': this.authToken,
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({
                    parentNoteId,
                    title,
                    type: 'text',
                    content
                })
            }
        );
        return response.json();
    }
}

Python Client Example

import requests

class TriliumETAPI:
    def __init__(self, server_url, auth_token):
        self.server_url = server_url
        self.auth_token = auth_token
        self.headers = {'Authorization': auth_token}
    
    def get_note(self, note_id):
        response = requests.get(
            f"{self.server_url}/etapi/notes/{note_id}",
            headers=self.headers
        )
        return response.json()
    
    def create_note(self, parent_note_id, title, content=""):
        response = requests.post(
            f"{self.server_url}/etapi/notes",
            headers=self.headers,
            json={
                'parentNoteId': parent_note_id,
                'title': title,
                'type': 'text',
                'content': content
            }
        )
        return response.json()
    
    def search_notes(self, query):
        response = requests.get(
            f"{self.server_url}/etapi/notes/search",
            headers=self.headers,
            params={'search': query}
        )
        return response.json()

WebSocket Real-time Synchronization

Location: /apps/server/src/services/ws.ts

WebSocket connections provide real-time updates and synchronization between clients.

Architecture

// WebSocket message types
interface WSMessage {
    type: string;
    data: any;
}

// Common message types
type MessageType = 
    | 'entity-changes'      // Entity updates
    | 'sync'               // Sync events
    | 'note-content-change' // Content updates
    | 'refresh-tree'       // Tree structure changes
    | 'options-changed'    // Configuration updates

Connection Management

// Client connection
const ws = new WebSocket('wss://server/ws');

ws.on('open', () => {
    // Authenticate
    ws.send(JSON.stringify({
        type: 'auth',
        token: sessionToken
    }));
});

ws.on('message', (data) => {
    const message = JSON.parse(data);
    handleWSMessage(message);
});

// Server-side handling
import WebSocket from 'ws';

const wss = new WebSocket.Server({ server });

wss.on('connection', (ws, req) => {
    const session = parseSession(req);
    
    if (!session.authenticated) {
        ws.close(1008, 'Not authenticated');
        return;
    }
    
    clients.add(ws);
    
    ws.on('message', (message) => {
        handleClientMessage(ws, message);
    });
    
    ws.on('close', () => {
        clients.delete(ws);
    });
});

Message Broadcasting

// Broadcast entity changes
function broadcastEntityChanges(changes: EntityChange[]) {
    const message = {
        type: 'entity-changes',
        data: changes
    };
    
    for (const client of clients) {
        if (client.readyState === WebSocket.OPEN) {
            client.send(JSON.stringify(message));
        }
    }
}

// Targeted messages
function sendToClient(clientId: string, message: WSMessage) {
    const client = clients.get(clientId);
    if (client?.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(message));
    }
}

Real-time Sync Protocol

// Entity change notification
{
    type: 'entity-changes',
    data: [
        {
            entityName: 'notes',
            entityId: 'noteId123',
            action: 'update',
            entity: { /* note data */ }
        }
    ]
}

// Sync pull request
{
    type: 'sync-pull',
    data: {
        lastSyncId: 12345
    }
}

// Sync push
{
    type: 'sync-push',
    data: {
        entities: [ /* changed entities */ ]
    }
}

Client-side Handling

// Froca WebSocket integration
class WSClient {
    constructor() {
        this.ws = null;
        this.reconnectTimeout = null;
        this.connect();
    }
    
    connect() {
        this.ws = new WebSocket(this.getWSUrl());
        
        this.ws.onmessage = (event) => {
            const message = JSON.parse(event.data);
            this.handleMessage(message);
        };
        
        this.ws.onclose = () => {
            // Reconnect with exponential backoff
            this.scheduleReconnect();
        };
    }
    
    handleMessage(message: WSMessage) {
        switch (message.type) {
            case 'entity-changes':
                this.handleEntityChanges(message.data);
                break;
            case 'refresh-tree':
                froca.loadInitialTree();
                break;
            case 'note-content-change':
                this.handleContentChange(message.data);
                break;
        }
    }
    
    handleEntityChanges(changes: EntityChange[]) {
        for (const change of changes) {
            if (change.entityName === 'notes') {
                froca.reloadNotes([change.entityId]);
            }
        }
    }
}

API Security

Authentication Methods

// 1. Session-based (Internal API)
app.use(session({
    secret: config.sessionSecret,
    resave: false,
    saveUninitialized: false
}));

// 2. Token-based (ETAPI)
router.use('/etapi', (req, res, next) => {
    const token = req.headers.authorization;
    
    const etapiToken = becca.getEtapiToken(token);
    if (!etapiToken || etapiToken.isExpired()) {
        return res.status(401).json({ 
            error: 'Invalid or expired token' 
        });
    }
    
    req.etapiToken = etapiToken;
    next();
});

// 3. WebSocket authentication
ws.on('connection', (socket) => {
    socket.on('auth', (token) => {
        if (!validateToken(token)) {
            socket.close(1008, 'Invalid token');
        }
    });
});

Rate Limiting

import rateLimit from 'express-rate-limit';

// Global rate limit
const globalLimiter = rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 1000 // limit each IP to 1000 requests per windowMs
});

// Strict limit for authentication
const authLimiter = rateLimit({
    windowMs: 15 * 60 * 1000,
    max: 5,
    message: 'Too many authentication attempts'
});

app.use('/api', globalLimiter);
app.use('/api/auth', authLimiter);

Input Validation

import { body, validationResult } from 'express-validator';

router.post('/api/notes',
    body('title').isString().isLength({ min: 1, max: 1000 }),
    body('type').isIn(['text', 'code', 'file', 'image']),
    body('content').optional().isString(),
    (req, res) => {
        const errors = validationResult(req);
        if (!errors.isEmpty()) {
            return res.status(400).json({ 
                errors: errors.array() 
            });
        }
        
        // Process valid input
    }
);

Performance Optimization

Caching Strategies

// Response caching
const cache = new Map();

router.get('/api/notes/:noteId', (req, res) => {
    const cacheKey = `note:${req.params.noteId}`;
    const cached = cache.get(cacheKey);
    
    if (cached && cached.expires > Date.now()) {
        return res.json(cached.data);
    }
    
    const note = becca.getNote(req.params.noteId);
    const data = note.getPojo();
    
    cache.set(cacheKey, {
        data,
        expires: Date.now() + 60000 // 1 minute
    });
    
    res.json(data);
});

Batch Operations

// Batch API endpoint
router.post('/api/batch', async (req, res) => {
    const operations = req.body.operations;
    const results = [];
    
    await sql.transactional(async () => {
        for (const op of operations) {
            const result = await executeOperation(op);
            results.push(result);
        }
    });
    
    res.json({ results });
});

// Client batch usage
const batch = [
    { method: 'PUT', path: '/notes/1', body: { title: 'Note 1' }},
    { method: 'PUT', path: '/notes/2', body: { title: 'Note 2' }},
    { method: 'POST', path: '/notes/3/attributes', body: { type: 'label', name: 'todo' }}
];

await api.post('/batch', { operations: batch });

Streaming Responses

// Stream large data
router.get('/api/export', (req, res) => {
    res.writeHead(200, {
        'Content-Type': 'application/x-ndjson',
        'Transfer-Encoding': 'chunked'
    });
    
    const noteStream = createNoteExportStream();
    
    noteStream.on('data', (note) => {
        res.write(JSON.stringify(note) + '\n');
    });
    
    noteStream.on('end', () => {
        res.end();
    });
});

Error Handling

Standard Error Responses

// Error response format
interface ErrorResponse {
    error: string;
    code?: string;
    details?: any;
}

// Error handling middleware
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
    console.error('API Error:', err);
    
    if (err instanceof NotFoundError) {
        return res.status(404).json({
            error: err.message,
            code: 'NOT_FOUND'
        });
    }
    
    if (err instanceof ValidationError) {
        return res.status(400).json({
            error: err.message,
            code: 'VALIDATION_ERROR',
            details: err.details
        });
    }
    
    // Generic error
    res.status(500).json({
        error: 'Internal server error',
        code: 'INTERNAL_ERROR'
    });
});

API Documentation

OpenAPI/Swagger

# etapi.openapi.yaml
openapi: 3.0.0
info:
  title: Trilium ETAPI
  version: 1.0.0
  description: External API for Trilium Notes

paths:
  /etapi/notes/{noteId}:
    get:
      summary: Get note by ID
      parameters:
        - name: noteId
          in: path
          required: true
          schema:
            type: string
      responses:
        200:
          description: Note found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Note'
        404:
          description: Note not found

components:
  schemas:
    Note:
      type: object
      properties:
        noteId:
          type: string
        title:
          type: string
        type:
          type: string
          enum: [text, code, file, image]

API Testing

// API test example
describe('Notes API', () => {
    it('should create a note', async () => {
        const response = await request(app)
            .post('/api/notes/root/children')
            .send({
                title: 'Test Note',
                type: 'text',
                content: 'Test content'
            })
            .expect(200);
            
        expect(response.body).toHaveProperty('noteId');
        expect(response.body.title).toBe('Test Note');
    });
    
    it('should handle errors', async () => {
        const response = await request(app)
            .get('/api/notes/invalid')
            .expect(404);
            
        expect(response.body).toHaveProperty('error');
    });
});

Best Practices

API Design

  1. RESTful conventions: Use appropriate HTTP methods and status codes
  2. Consistent naming: Use camelCase for JSON properties
  3. Versioning: Version the API to maintain compatibility
  4. Documentation: Keep OpenAPI spec up to date

Security

  1. Authentication: Always verify user identity
  2. Authorization: Check permissions for each operation
  3. Validation: Validate all input data
  4. Rate limiting: Prevent abuse with appropriate limits

Performance

  1. Pagination: Limit response sizes with pagination
  2. Caching: Cache frequently accessed data
  3. Batch operations: Support bulk operations
  4. Async processing: Use queues for long-running tasks