315 lines
9.6 KiB
TypeScript
315 lines
9.6 KiB
TypeScript
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
import { z } from "zod";
|
|
import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js";
|
|
import { findProject } from "../fixtures/projectFixtures.js";
|
|
import {
|
|
applyRelationshipFilters,
|
|
createRelationship,
|
|
deleteRelationship,
|
|
findRelationship,
|
|
listRelationshipsForProject,
|
|
} from "../fixtures/relationshipFixtures.js";
|
|
import { getRequestId } from "../plugins/requestId.js";
|
|
import type {
|
|
RelationshipListFilters,
|
|
RelationshipType,
|
|
ReviewMutationResponseDto,
|
|
} from "../types/api.js";
|
|
import { errorEnvelope, successEnvelope } from "../utils/envelope.js";
|
|
import {
|
|
getIdempotencyKey,
|
|
getIdempotentResponse,
|
|
setIdempotentResponse,
|
|
} from "../utils/idempotency.js";
|
|
|
|
const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-]+$/);
|
|
const projectParamsSchema = z.object({ projectId: idSchema });
|
|
const relationshipParamsSchema = z.object({
|
|
projectId: idSchema,
|
|
relationshipId: idSchema,
|
|
});
|
|
const paginationQuerySchema = z.object({
|
|
page: z.coerce.number().int().positive().default(1),
|
|
pageSize: z.coerce.number().int().positive().max(100).default(25),
|
|
cursor: z.string().optional(),
|
|
sort: z.string().optional(),
|
|
});
|
|
const relationshipTypes = [
|
|
"depends_on",
|
|
"conflicts_with",
|
|
"duplicates",
|
|
"supersedes",
|
|
"belongs_to",
|
|
"implements",
|
|
"references",
|
|
] as const;
|
|
const relationshipQuerySchema = paginationQuerySchema.extend({
|
|
from: z.string().optional(),
|
|
to: z.string().optional(),
|
|
type: z.string().optional(),
|
|
confidence: z.coerce.number().optional(),
|
|
strength: z.coerce.number().int().min(1).max(5).optional(),
|
|
});
|
|
const createRelationshipBodySchema = z.object({
|
|
from: z.string().optional(),
|
|
to: z.string().optional(),
|
|
type: z.enum(relationshipTypes).optional(),
|
|
rationale: z.string().optional(),
|
|
strength: z.number().int().min(1).max(5).optional(),
|
|
confidence: z.number().min(0).max(5).optional(),
|
|
});
|
|
|
|
const sendValidationError = (
|
|
request: FastifyRequest,
|
|
reply: FastifyReply,
|
|
validation: z.ZodIssue[],
|
|
) =>
|
|
reply.status(400).send(
|
|
errorEnvelope(
|
|
"VALIDATION_ERROR",
|
|
"Request validation failed",
|
|
getRequestId(request),
|
|
validation.map((issue) => ({
|
|
field: issue.path.join("."),
|
|
message: issue.message,
|
|
code: issue.code,
|
|
})),
|
|
),
|
|
);
|
|
|
|
const parseParams = <TSchema extends z.ZodTypeAny>(
|
|
schema: TSchema,
|
|
request: FastifyRequest,
|
|
reply: FastifyReply,
|
|
): z.output<TSchema> | undefined => {
|
|
const parsed = schema.safeParse(request.params);
|
|
if (!parsed.success) {
|
|
sendValidationError(request, reply, parsed.error.issues);
|
|
return undefined;
|
|
}
|
|
return parsed.data;
|
|
};
|
|
|
|
const parseQuery = <TSchema extends z.ZodTypeAny>(
|
|
schema: TSchema,
|
|
request: FastifyRequest,
|
|
reply: FastifyReply,
|
|
): z.output<TSchema> | undefined => {
|
|
const parsed = schema.safeParse(request.query);
|
|
if (!parsed.success) {
|
|
sendValidationError(request, reply, parsed.error.issues);
|
|
return undefined;
|
|
}
|
|
return parsed.data;
|
|
};
|
|
|
|
const ensureProject = (
|
|
request: FastifyRequest,
|
|
reply: FastifyReply,
|
|
projectId: string,
|
|
) => {
|
|
const project = findProject(projectId);
|
|
if (!project) {
|
|
reply.status(404).send(
|
|
errorEnvelope(
|
|
"PROJECT_NOT_FOUND",
|
|
`Project '${projectId}' was not found.`,
|
|
getRequestId(request),
|
|
),
|
|
);
|
|
return undefined;
|
|
}
|
|
return project;
|
|
};
|
|
|
|
const splitCsv = (value: string | undefined): string[] =>
|
|
value?.split(",").map((item) => item.trim()).filter(Boolean) ?? [];
|
|
|
|
const sortByField = <T extends Record<string, unknown>>(
|
|
items: T[],
|
|
sort: string | undefined,
|
|
): T[] => {
|
|
if (!sort) return items;
|
|
const [field, direction = "asc"] = sort.split(":");
|
|
if (!field) return items;
|
|
return [...items].sort((left, right) => {
|
|
const comparison = String(left[field] ?? "").localeCompare(
|
|
String(right[field] ?? ""),
|
|
undefined,
|
|
{ numeric: true },
|
|
);
|
|
return direction === "desc" ? -comparison : comparison;
|
|
});
|
|
};
|
|
|
|
const toPaginationInput = (
|
|
query: z.output<typeof paginationQuerySchema>,
|
|
filters: PaginationInput["filters"],
|
|
): PaginationInput => ({
|
|
page: query.page,
|
|
pageSize: query.pageSize,
|
|
cursor: query.cursor,
|
|
sort: query.sort,
|
|
filters,
|
|
});
|
|
|
|
const toFilters = (
|
|
query: z.output<typeof relationshipQuerySchema>,
|
|
): RelationshipListFilters => ({
|
|
...(query.from ? { from: query.from } : {}),
|
|
...(query.to ? { to: query.to } : {}),
|
|
...(query.type ? { type: splitCsv(query.type) as RelationshipType[] } : {}),
|
|
...(query.confidence !== undefined ? { confidence: query.confidence } : {}),
|
|
...(query.strength !== undefined ? { strength: query.strength } : {}),
|
|
...(query.cursor ? { cursor: query.cursor } : {}),
|
|
});
|
|
|
|
const getRequiredIdempotencyKey = (
|
|
request: FastifyRequest,
|
|
reply: FastifyReply,
|
|
): string | undefined => {
|
|
const rawKey = request.headers["idempotency-key"];
|
|
const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey;
|
|
if (!idempotencyKey?.trim()) {
|
|
reply.status(400).send(
|
|
errorEnvelope(
|
|
"IDEMPOTENCY_KEY_REQUIRED",
|
|
"Idempotency-Key header is required.",
|
|
getRequestId(request),
|
|
),
|
|
);
|
|
return undefined;
|
|
}
|
|
return idempotencyKey;
|
|
};
|
|
|
|
const sendRelationshipNotFound = (
|
|
request: FastifyRequest,
|
|
reply: FastifyReply,
|
|
relationshipId: string,
|
|
) =>
|
|
reply.status(404).send(
|
|
errorEnvelope(
|
|
"RELATIONSHIP_NOT_FOUND",
|
|
`Relationship '${relationshipId}' was not found.`,
|
|
getRequestId(request),
|
|
),
|
|
);
|
|
|
|
export const registerRelationshipRoutes = async (app: FastifyInstance) => {
|
|
app.get("/api/projects/:projectId/relationships", async (request, reply) => {
|
|
const params = parseParams(projectParamsSchema, request, reply);
|
|
if (!params) return reply;
|
|
if (!ensureProject(request, reply, params.projectId)) return reply;
|
|
const query = parseQuery(relationshipQuerySchema, request, reply);
|
|
if (!query) return reply;
|
|
|
|
const filters = toFilters(query);
|
|
const relationships = sortByField(
|
|
applyRelationshipFilters(
|
|
listRelationshipsForProject(params.projectId),
|
|
filters,
|
|
),
|
|
query.sort,
|
|
);
|
|
|
|
return successEnvelope(
|
|
paginate(relationships, toPaginationInput(query, filters)),
|
|
getRequestId(request),
|
|
);
|
|
});
|
|
|
|
app.get(
|
|
"/api/projects/:projectId/relationships/:relationshipId",
|
|
async (request, reply) => {
|
|
const params = parseParams(relationshipParamsSchema, request, reply);
|
|
if (!params) return reply;
|
|
if (!ensureProject(request, reply, params.projectId)) return reply;
|
|
const relationship = findRelationship(params.projectId, params.relationshipId);
|
|
if (!relationship) {
|
|
return sendRelationshipNotFound(request, reply, params.relationshipId);
|
|
}
|
|
return successEnvelope(relationship, getRequestId(request));
|
|
},
|
|
);
|
|
|
|
app.post("/api/projects/:projectId/relationships", async (request, reply) => {
|
|
const params = parseParams(projectParamsSchema, request, reply);
|
|
if (!params) return reply;
|
|
if (!ensureProject(request, reply, params.projectId)) return reply;
|
|
const idempotencyKey = getRequiredIdempotencyKey(request, reply);
|
|
if (!idempotencyKey) return reply;
|
|
const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey);
|
|
const replay = getIdempotentResponse<unknown>(cacheKey);
|
|
if (replay) return reply.status(replay.statusCode).send(replay.body);
|
|
|
|
const parsedBody = createRelationshipBodySchema.safeParse(request.body);
|
|
if (
|
|
!parsedBody.success ||
|
|
!parsedBody.data.from?.trim() ||
|
|
!parsedBody.data.to?.trim() ||
|
|
!parsedBody.data.type ||
|
|
!parsedBody.data.rationale?.trim()
|
|
) {
|
|
return reply.status(400).send(
|
|
errorEnvelope(
|
|
"RELATIONSHIP_VALIDATION_FAILED",
|
|
"from, to, type and rationale are required to create a relationship.",
|
|
getRequestId(request),
|
|
),
|
|
);
|
|
}
|
|
|
|
const body = successEnvelope(
|
|
createRelationship(params.projectId, {
|
|
...parsedBody.data,
|
|
from: parsedBody.data.from,
|
|
to: parsedBody.data.to,
|
|
type: parsedBody.data.type,
|
|
rationale: parsedBody.data.rationale,
|
|
}),
|
|
getRequestId(request),
|
|
);
|
|
setIdempotentResponse(cacheKey, { statusCode: 201, body });
|
|
return reply.status(201).send(body);
|
|
});
|
|
|
|
app.delete(
|
|
"/api/projects/:projectId/relationships/:relationshipId",
|
|
async (request, reply) => {
|
|
const params = parseParams(relationshipParamsSchema, request, reply);
|
|
if (!params) return reply;
|
|
if (!ensureProject(request, reply, params.projectId)) return reply;
|
|
const idempotencyKey = getRequiredIdempotencyKey(request, reply);
|
|
if (!idempotencyKey) return reply;
|
|
const cacheKey = getIdempotencyKey(
|
|
request.method,
|
|
request.url,
|
|
idempotencyKey,
|
|
);
|
|
const replay = getIdempotentResponse<unknown>(cacheKey);
|
|
if (replay) return reply.status(replay.statusCode).send(replay.body);
|
|
|
|
const relationship = deleteRelationship(
|
|
params.projectId,
|
|
params.relationshipId,
|
|
);
|
|
if (!relationship) {
|
|
return sendRelationshipNotFound(request, reply, params.relationshipId);
|
|
}
|
|
|
|
const body = successEnvelope<ReviewMutationResponseDto>(
|
|
{
|
|
success: true,
|
|
message: `Relationship '${params.relationshipId}' deleted.`,
|
|
updatedEntityId: params.relationshipId,
|
|
affectedEntities: [params.relationshipId],
|
|
},
|
|
getRequestId(request),
|
|
);
|
|
setIdempotentResponse(cacheKey, { statusCode: 200, body });
|
|
return reply.status(200).send(body);
|
|
},
|
|
);
|
|
};
|