datafold 0.1.55

A personal database for data sovereignty with AI-powered ingestion
Documentation
#!/usr/bin/env node
/*
  Generate src/api/endpoints.ts from the backend OpenAPI spec.
  Usage: node scripts/generate-endpoints.js [openapi_url_or_path]
  Defaults to http://localhost:9001/api/openapi.json
*/

import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import https from 'node:https';
import http from 'node:http';

const OPENAPI_INPUT = process.argv[2] || process.env.OPENAPI_URL || 'http://localhost:9001/api/openapi.json';
// Ensure output always targets the static-react project, even if run from repo root
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
const projectRoot = path.resolve(scriptDir, '..');
const outputFile = path.join(projectRoot, 'src', 'api', 'endpoints.ts');

function fetchJsonFromHttp(url) {
  return new Promise((resolve, reject) => {
    const client = url.startsWith('https') ? https : http;
    client
      .get(url, (res) => {
        if (res.statusCode !== 200) {
          reject(new Error(`Failed to fetch ${url}: ${res.statusCode}`));
          return;
        }
        let data = '';
        res.on('data', (chunk) => (data += chunk));
        res.on('end', () => {
          try {
            resolve(JSON.parse(data));
          } catch (e) {
            reject(new Error(`Failed to parse JSON from ${url}: ${e.message}`));
          }
        });
      })
      .on('error', (err) => reject(new Error(`Failed to fetch ${url}: ${err.message}`)));
  });
}

function loadSpec(input) {
  if (input.startsWith('http://') || input.startsWith('https://')) {
    return fetchJsonFromHttp(input);
  }
  // file path or file:// URL
  const p = input.startsWith('file://') ? input.slice('file://'.length) : input;
  const abs = path.isAbsolute(p) ? p : path.join(process.cwd(), p);

  if (!fs.existsSync(abs)) {
    const hint = 'Set OPENAPI_URL to a local openapi.json file (for example ./target/openapi.json).';
    throw new Error(`OpenAPI spec not found at ${abs}. ${hint}`);
  }

  try {
    const raw = fs.readFileSync(abs, 'utf8');
    return Promise.resolve(JSON.parse(raw));
  } catch (e) {
    throw new Error(`Failed to load OpenAPI spec from ${abs}: ${e.message}`);
  }
}

function emit(filePath, content) {
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  fs.writeFileSync(filePath, content, 'utf8');
}

function header(sourceHint) {
  return `// AUTO-GENERATED by scripts/generate-endpoints.js from ${sourceHint}\n// Do not edit manually. Run: npm run generate:endpoints\n\n`;
}

function toConstCase(name) {
  return String(name)
    .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
    .replace(/[^a-zA-Z0-9]+/g, '_')
    .replace(/^_+|_+$/g, '')
    .toUpperCase();
}

function pathToBuilder(pathStr) {
  const withoutApi = pathStr.replace(/^\/api/, '');
  const params = Array.from(withoutApi.matchAll(/\{(\w+)\}/g)).map((m) => m[1]);
  if (params.length === 0) {
    return { kind: 'static', code: `'${withoutApi}'` };
  }
  const args = params.join(', ');
  const template = withoutApi.replace(/\{(\w+)\}/g, (_m, p1) => '${' + p1 + '}');
  return { kind: 'param', code: `(${args}) => \`${template}\`` };
}

// Build a typed parameter signature using OpenAPI types with a safe fallback
function buildTypedParamSignature(pathStr, method, params) {
  // Use a conditional type helper to avoid type errors when params are missing
  // type PathParams<P extends keyof paths, M extends keyof paths[P]> =
  //   paths[P][M] extends { parameters: { path: infer T } } ? T : Record<string, string>
  return params
    .map((p) => `${p}: PathParams<'${pathStr}', '${method}'>['${p}']`)
    .join(', ');
}

function generateEndpointsFromSpec(spec) {
  const paths = spec.paths || {};

  // Build a map of path -> preferred operationId (choose GET > POST > PUT > others)
  const methodPriority = ['get', 'post', 'put', 'patch', 'delete'];
  const pathInfo = new Map();

  for (const [p, methods] of Object.entries(paths)) {
    let chosen = null;
    for (const m of methodPriority) {
      if (methods[m]) {
        chosen = { method: m, op: methods[m] };
        break;
      }
    }
    if (!chosen) {
      const firstMethod = Object.keys(methods)[0];
      if (firstMethod) chosen = { method: firstMethod, op: methods[firstMethod] };
    }
    if (chosen) {
      const opId = chosen.op.operationId || `${chosen.method}_${p.replace(/\{|\}/g, '')}`;
      pathInfo.set(p, { opId, method: chosen.method });
    }
  }

  // Derived endpoints for all paths
  const derivedLines = [];
  derivedLines.push("import type { paths } from '../types/openapi'\n");
  derivedLines.push('type PathParams<P extends keyof paths, M extends keyof paths[P]> =');
  derivedLines.push("  paths[P][M] extends { parameters: { path: infer T } } ? T : Record<string, string>\n");
  derivedLines.push('export const API_ENDPOINTS_DERIVED = {');
  for (const [p, info] of pathInfo.entries()) {
    const key = toConstCase(info.opId);
    const builder = pathToBuilder(p);
    if (builder.kind === 'param') {
      const params = Array.from(p.matchAll(/\{(\w+)\}/g)).map((m) => m[1]);
      const typedSig = buildTypedParamSignature(p, info.method, params);
      const code = builder.code.replace(/^\(([^)]*)\)/, `(${typedSig})`);
      derivedLines.push(`  ${key}: ${code},`);
    } else {
      derivedLines.push(`  ${key}: ${builder.code},`);
    }
  }
  derivedLines.push('} as const;');

  // Generate API_BASE_URLS from spec path prefixes
  const baseSegments = new Set();
  for (const p of Object.keys(paths)) {
    const m = p.match(/^\/api\/?([^\/]+)/);
    if (m && m[1]) baseSegments.add(m[1]);
  }

  const baseUrlsLines = [];
  baseUrlsLines.push('// Base URL prefixes derived from OpenAPI paths');
  baseUrlsLines.push('export const API_BASE_URLS = {');
  baseUrlsLines.push("  ROOT: '/api',");
  Array.from(baseSegments)
    .sort()
    .forEach((seg) => {
      const key = toConstCase(seg);
      baseUrlsLines.push(`  ${key}: '/api/${seg}',`);
    });
  baseUrlsLines.push('} as const;');

  // Export only OpenAPI-generated endpoints (no hardcoded aliases)
  const lines = [];
  lines.push(...derivedLines);
  lines.push('');
  lines.push(...baseUrlsLines);
  lines.push('');
  lines.push('// Export generated endpoints directly (no hardcoded aliases)');
  lines.push('export const API_ENDPOINTS = API_ENDPOINTS_DERIVED;');
  lines.push('export type ApiEndpoint = typeof API_ENDPOINTS[keyof typeof API_ENDPOINTS];');

  const content = header(OPENAPI_INPUT) + lines.join('\n') + '\n';
  return { content, missing: [] };
}

async function main() {
  try {
    const spec = await loadSpec(OPENAPI_INPUT);
    const { content, missing } = generateEndpointsFromSpec(spec);
    if (missing.length) {
      console.warn('[generate-endpoints] Missing in OpenAPI spec:', missing.join(', '));
    }
    emit(outputFile, content);
    console.log(`[generate-endpoints] Wrote ${outputFile}`);
  } catch (e) {
    console.error('[generate-endpoints] Failed:', e.message);
    console.error('[generate-endpoints] Provide OPENAPI_URL to a local spec file (e.g. ./target/openapi.json).');
    process.exit(1);
  }
}

main();