- Service API: list (filtered by type/owner/lifecycle/tier), detail, upsert, delete, ownership summary - Discovery API: trigger AWS/GitHub scans, scan history, staged update review (apply/reject) - Search: Meilisearch full-text with PG ILIKE fallback, reindex endpoint - Data layer: withTenant() RLS wrapper, Zod config with MEILI_URL/MEILI_KEY - Fastify server entry point
115 lines
4.2 KiB
TypeScript
115 lines
4.2 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { z } from 'zod';
|
|
import { withTenant } from '../data/db.js';
|
|
|
|
const listQuerySchema = z.object({
|
|
page: z.coerce.number().min(1).default(1),
|
|
limit: z.coerce.number().min(1).max(100).default(20),
|
|
type: z.string().optional(),
|
|
owner: z.string().optional(),
|
|
lifecycle: z.enum(['active', 'deprecated', 'decommissioned']).optional(),
|
|
tier: z.enum(['critical', 'high', 'medium', 'low']).optional(),
|
|
});
|
|
|
|
const upsertServiceSchema = z.object({
|
|
name: z.string().min(1).max(200),
|
|
type: z.string().default('unknown'),
|
|
owner: z.string().default('unknown'),
|
|
description: z.string().max(2000).optional(),
|
|
tier: z.enum(['critical', 'high', 'medium', 'low']).default('medium'),
|
|
lifecycle: z.enum(['active', 'deprecated', 'decommissioned']).default('active'),
|
|
links: z.record(z.string()).default({}),
|
|
tags: z.record(z.string()).default({}),
|
|
});
|
|
|
|
export function registerServiceRoutes(app: FastifyInstance) {
|
|
// List services
|
|
app.get('/api/v1/services', async (req, reply) => {
|
|
const query = listQuerySchema.parse(req.query);
|
|
const tenantId = (req as any).tenantId;
|
|
const offset = (query.page - 1) * query.limit;
|
|
|
|
const result = await withTenant(tenantId, async (client) => {
|
|
let sql = 'SELECT * FROM services WHERE 1=1';
|
|
const params: any[] = [];
|
|
let idx = 1;
|
|
|
|
if (query.type) { sql += ` AND type = $${idx++}`; params.push(query.type); }
|
|
if (query.owner) { sql += ` AND owner = $${idx++}`; params.push(query.owner); }
|
|
if (query.lifecycle) { sql += ` AND lifecycle = $${idx++}`; params.push(query.lifecycle); }
|
|
if (query.tier) { sql += ` AND tier = $${idx++}`; params.push(query.tier); }
|
|
|
|
sql += ` ORDER BY name LIMIT $${idx++} OFFSET $${idx++}`;
|
|
params.push(query.limit, offset);
|
|
|
|
return client.query(sql, params);
|
|
});
|
|
|
|
return { services: result.rows, page: query.page, limit: query.limit };
|
|
});
|
|
|
|
// Get service detail
|
|
app.get('/api/v1/services/:id', async (req, reply) => {
|
|
const { id } = req.params as { id: string };
|
|
const tenantId = (req as any).tenantId;
|
|
|
|
const result = await withTenant(tenantId, async (client) => {
|
|
return client.query('SELECT * FROM services WHERE id = $1', [id]);
|
|
});
|
|
|
|
if (!result.rows[0]) return reply.status(404).send({ error: 'Not found' });
|
|
return { service: result.rows[0] };
|
|
});
|
|
|
|
// Create/update service (manual entry)
|
|
app.put('/api/v1/services', async (req, reply) => {
|
|
const body = upsertServiceSchema.parse(req.body);
|
|
const tenantId = (req as any).tenantId;
|
|
|
|
const result = await withTenant(tenantId, async (client) => {
|
|
return client.query(
|
|
`INSERT INTO services (tenant_id, name, type, owner, owner_source, description, tier, lifecycle, links, tags)
|
|
VALUES ($1, $2, $3, $4, 'config', $5, $6, $7, $8, $9)
|
|
ON CONFLICT (tenant_id, name) DO UPDATE SET
|
|
type = EXCLUDED.type, owner = EXCLUDED.owner, owner_source = 'config',
|
|
description = EXCLUDED.description, tier = EXCLUDED.tier, lifecycle = EXCLUDED.lifecycle,
|
|
links = EXCLUDED.links, tags = EXCLUDED.tags, updated_at = now()
|
|
RETURNING *`,
|
|
[tenantId, body.name, body.type, body.owner, body.description, body.tier, body.lifecycle, JSON.stringify(body.links), JSON.stringify(body.tags)],
|
|
);
|
|
});
|
|
|
|
return reply.status(201).send({ service: result.rows[0] });
|
|
});
|
|
|
|
// Delete service
|
|
app.delete('/api/v1/services/:id', async (req, reply) => {
|
|
const { id } = req.params as { id: string };
|
|
const tenantId = (req as any).tenantId;
|
|
|
|
await withTenant(tenantId, async (client) => {
|
|
await client.query('DELETE FROM services WHERE id = $1', [id]);
|
|
});
|
|
|
|
return { status: 'deleted' };
|
|
});
|
|
|
|
// Ownership summary (who owns what)
|
|
app.get('/api/v1/ownership', async (req, reply) => {
|
|
const tenantId = (req as any).tenantId;
|
|
|
|
const result = await withTenant(tenantId, async (client) => {
|
|
return client.query(`
|
|
SELECT owner, owner_source, COUNT(*)::int as service_count,
|
|
array_agg(name ORDER BY name) as services
|
|
FROM services
|
|
WHERE lifecycle = 'active'
|
|
GROUP BY owner, owner_source
|
|
ORDER BY service_count DESC
|
|
`);
|
|
});
|
|
|
|
return { owners: result.rows };
|
|
});
|
|
}
|