4 min read

Cat Lexicon Server Prototype

I'm short on time again - had a wedding ceremony to attend last night and a reception to attend today - so today's another 5-minute throwaway. I set up a quick server prototype that I can run some basic curl commands to and from and that validates my lexicon schema. This is all local for now and not attached to a PDS, so URIs are fake.

lexicons.js:

import express from 'express'
import { TID } from '@atproto/common-web'
import { lex } from './lexicons.js'

const app = express()
const PORT = 3000

// In-memory storage for records
const records = new Map()

app.use(express.json())

// POST /xrpc/com.atproto.repo.createRecord
app.post('/xrpc/com.atproto.repo.createRecord', (req, res) => {
  try {
    const { repo, collection, record } = req.body

    if (!repo || !collection || !record) {
      return res.status(400).json({
        error: 'InvalidRequest',
        message: 'Missing required fields: repo, collection, record'
      })
    }

    // Validate the record against its lexicon
    try {
      lex.assertValidRecord(collection, record)
    } catch (validationError) {
      return res.status(400).json({
        error: 'InvalidRecord',
        message: validationError.message
      })
    }

    // Generate a TID for the record key
    const rkey = TID.nextStr()
    const uri = `at://${repo}/${collection}/${rkey}`

    // Store the record
    if (!records.has(collection)) {
      records.set(collection, new Map())
    }
    records.get(collection).set(rkey, {
      uri,
      cid: 'bafyreib2rxk3rh6kzwstatue', // Mock CID
      value: record
    })

    res.json({
      uri,
      cid: 'bafyreib2rxk3rh6kzwstate'
    })

    console.log(`✓ Created record: ${uri}`)
  } catch (error) {
    console.error('Error creating record:', error)
    res.status(500).json({
      error: 'InternalServerError',
      message: error.message
    })
  }
})

// GET /xrpc/com.atproto.repo.listRecords
app.get('/xrpc/com.atproto.repo.listRecords', (req, res) => {
  try {
    const { repo, collection, limit = 50 } = req.query

    if (!repo || !collection) {
      return res.status(400).json({
        error: 'InvalidRequest',
        message: 'Missing required parameters: repo, collection'
      })
    }

    const collectionRecords = records.get(collection)
    if (!collectionRecords) {
      return res.json({ records: [] })
    }

    // Get records as array and sort by rkey (which are TIDs, timestamp-based)
    const recordArray = Array.from(collectionRecords.entries())
      .map(([rkey, record]) => ({
        uri: record.uri,
        cid: record.cid,
        value: record.value
      }))
      .sort((a, b) => b.uri.localeCompare(a.uri)) // Reverse chronological
      .slice(0, parseInt(limit))

    res.json({
      records: recordArray
    })

    console.log(`✓ Listed ${recordArray.length} records from ${collection}`)
  } catch (error) {
    console.error('Error listing records:', error)
    res.status(500).json({
      error: 'InternalServerError',
      message: error.message
    })
  }
})

// GET /xrpc/com.atproto.repo.getRecord
app.get('/xrpc/com.atproto.repo.getRecord', (req, res) => {
  try {
    const { repo, collection, rkey } = req.query

    if (!repo || !collection || !rkey) {
      return res.status(400).json({
        error: 'InvalidRequest',
        message: 'Missing required parameters: repo, collection, rkey'
      })
    }

    const collectionRecords = records.get(collection)
    if (!collectionRecords || !collectionRecords.has(rkey)) {
      return res.status(404).json({
        error: 'RecordNotFound',
        message: `Record not found: ${collection}/${rkey}`
      })
    }

    const record = collectionRecords.get(rkey)
    res.json(record)

    console.log(`✓ Retrieved record: ${record.uri}`)
  } catch (error) {
    console.error('Error getting record:', error)
    res.status(500).json({
      error: 'InternalServerError',
      message: error.message
    })
  }
})

app.listen(PORT, () => {
  console.log(`\nAvailable endpoints:`)
  console.log(`  POST   /xrpc/com.atproto.repo.createRecord`)
  console.log(`  GET    /xrpc/com.atproto.repo.listRecords`)
  console.log(`  GET    /xrpc/com.atproto.repo.getRecord`)
  console.log(`\nLexicons loaded: ${lex.defs.size} definitions`)
})

server.js:

import express from 'express'
import { TID } from '@atproto/common-web'
import { lex } from './lexicons.js'

const app = express()
const PORT = 3000

// In-memory storage for records
const records = new Map()

app.use(express.json())

// POST /xrpc/com.atproto.repo.createRecord
app.post('/xrpc/com.atproto.repo.createRecord', (req, res) => {
  try {
    const { repo, collection, record } = req.body

    if (!repo || !collection || !record) {
      return res.status(400).json({
        error: 'InvalidRequest',
        message: 'Missing required fields: repo, collection, record'
      })
    }

    // Validate the record against its lexicon
    try {
      lex.assertValidRecord(collection, record)
    } catch (validationError) {
      return res.status(400).json({
        error: 'InvalidRecord',
        message: validationError.message
      })
    }

    // Generate a TID for the record key
    const rkey = TID.nextStr()
    const uri = `at://${repo}/${collection}/${rkey}`

    // Store the record
    if (!records.has(collection)) {
      records.set(collection, new Map())
    }
    records.get(collection).set(rkey, {
      uri,
      cid: 'bafyreib2rxk3rh6kzwstatue', // Mock CID
      value: record
    })

    res.json({
      uri,
      cid: 'bafyreib2rxk3rh6kzwstate'
    })

    console.log(`✓ Created record: ${uri}`)
  } catch (error) {
    console.error('Error creating record:', error)
    res.status(500).json({
      error: 'InternalServerError',
      message: error.message
    })
  }
})

// GET /xrpc/com.atproto.repo.listRecords
app.get('/xrpc/com.atproto.repo.listRecords', (req, res) => {
  try {
    const { repo, collection, limit = 50 } = req.query

    if (!repo || !collection) {
      return res.status(400).json({
        error: 'InvalidRequest',
        message: 'Missing required parameters: repo, collection'
      })
    }

    const collectionRecords = records.get(collection)
    if (!collectionRecords) {
      return res.json({ records: [] })
    }

    // Get records as array and sort by rkey (which are TIDs, timestamp-based)
    const recordArray = Array.from(collectionRecords.entries())
      .map(([rkey, record]) => ({
        uri: record.uri,
        cid: record.cid,
        value: record.value
      }))
      .sort((a, b) => b.uri.localeCompare(a.uri)) // Reverse chronological
      .slice(0, parseInt(limit))

    res.json({
      records: recordArray
    })

    console.log(`✓ Listed ${recordArray.length} records from ${collection}`)
  } catch (error) {
    console.error('Error listing records:', error)
    res.status(500).json({
      error: 'InternalServerError',
      message: error.message
    })
  }
})

// GET /xrpc/com.atproto.repo.getRecord
app.get('/xrpc/com.atproto.repo.getRecord', (req, res) => {
  try {
    const { repo, collection, rkey } = req.query

    if (!repo || !collection || !rkey) {
      return res.status(400).json({
        error: 'InvalidRequest',
        message: 'Missing required parameters: repo, collection, rkey'
      })
    }

    const collectionRecords = records.get(collection)
    if (!collectionRecords || !collectionRecords.has(rkey)) {
      return res.status(404).json({
        error: 'RecordNotFound',
        message: `Record not found: ${collection}/${rkey}`
      })
    }

    const record = collectionRecords.get(rkey)
    res.json(record)

    console.log(`✓ Retrieved record: ${record.uri}`)
  } catch (error) {
    console.error('Error getting record:', error)
    res.status(500).json({
      error: 'InternalServerError',
      message: error.message
    })
  }
})

app.listen(PORT, () => {
  console.log(`\nAvailable endpoints:`)
  console.log(`  POST   /xrpc/com.atproto.repo.createRecord`)
  console.log(`  GET    /xrpc/com.atproto.repo.listRecords`)
  console.log(`  GET    /xrpc/com.atproto.repo.getRecord`)
  console.log(`\nLexicons loaded: ${lex.defs.size} definitions`)
})

And, with the server running, I can GET and POST new records: