mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-14 14:57:00 +10:00
Feature: Implement Atomic Resume Patching API (#2692)
This commit is contained in:
@@ -77,6 +77,26 @@ export const resumeDto = {
|
||||
output: z.void(),
|
||||
},
|
||||
|
||||
patch: {
|
||||
input: z.object({
|
||||
id: z.string().describe("The ID of the resume to patch."),
|
||||
operations: z
|
||||
.array(
|
||||
z.object({
|
||||
op: z.enum(["add", "remove", "replace", "move", "copy", "test"]),
|
||||
path: z.string(),
|
||||
value: z.any().optional(),
|
||||
from: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.describe("An array of JSON Patch (RFC 6902) operations to apply to the resume data."),
|
||||
}),
|
||||
output: resumeSchema
|
||||
.omit({ password: true, userId: true, createdAt: true, updatedAt: true })
|
||||
.extend({ hasPassword: z.boolean() }),
|
||||
},
|
||||
|
||||
duplicate: {
|
||||
input: resumeSchema.pick({ id: true, name: true, slug: true, tags: true }),
|
||||
output: z.string().describe("The ID of the duplicated resume."),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Operation } from "fast-json-patch";
|
||||
import z from "zod";
|
||||
import { sampleResumeData } from "@/schema/resume/sample";
|
||||
import { generateRandomName, slugify } from "@/utils/string";
|
||||
@@ -193,6 +194,31 @@ export const resumeRouter = {
|
||||
});
|
||||
}),
|
||||
|
||||
patch: protectedProcedure
|
||||
.route({
|
||||
method: "PATCH",
|
||||
path: "/resume/{id}",
|
||||
tags: ["Resume"],
|
||||
summary: "Patch a resume",
|
||||
description:
|
||||
"Apply JSON Patch (RFC 6902) operations to partially update a resume's data. This allows you to make small, targeted changes without sending the entire resume object.",
|
||||
})
|
||||
.input(resumeDto.patch.input)
|
||||
.output(resumeDto.patch.output)
|
||||
.errors({
|
||||
INVALID_PATCH_OPERATIONS: {
|
||||
message: "The patch operations are invalid or produced an invalid resume.",
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.patch({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
operations: input.operations as Operation[],
|
||||
});
|
||||
}),
|
||||
|
||||
setLocked: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { and, arrayContains, asc, desc, eq, sql } from "drizzle-orm";
|
||||
import { get } from "es-toolkit/compat";
|
||||
import type { Operation } from "fast-json-patch";
|
||||
import { match } from "ts-pattern";
|
||||
import { schema } from "@/integrations/drizzle";
|
||||
import { db } from "@/integrations/drizzle/client";
|
||||
@@ -9,6 +10,7 @@ import { defaultResumeData } from "@/schema/resume/data";
|
||||
import { env } from "@/utils/env";
|
||||
import type { Locale } from "@/utils/locale";
|
||||
import { hashPassword } from "@/utils/password";
|
||||
import { applyResumePatches, ResumePatchError } from "@/utils/resume/patch";
|
||||
import { generateId } from "@/utils/string";
|
||||
import { hasResumeAccess } from "../helpers/resume-access";
|
||||
import { getStorageService } from "./storage";
|
||||
@@ -317,6 +319,54 @@ export const resumeService = {
|
||||
}
|
||||
},
|
||||
|
||||
patch: async (input: { id: string; userId: string; operations: Operation[] }) => {
|
||||
const [existing] = await db
|
||||
.select({ data: schema.resume.data, isLocked: schema.resume.isLocked })
|
||||
.from(schema.resume)
|
||||
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
|
||||
|
||||
if (!existing) throw new ORPCError("NOT_FOUND");
|
||||
if (existing.isLocked) throw new ORPCError("RESUME_LOCKED");
|
||||
|
||||
let patchedData: ResumeData;
|
||||
|
||||
try {
|
||||
patchedData = applyResumePatches(existing.data, input.operations);
|
||||
} catch (error) {
|
||||
if (error instanceof ResumePatchError) {
|
||||
throw new ORPCError("INVALID_PATCH_OPERATIONS", {
|
||||
status: 400,
|
||||
message: error.message,
|
||||
data: { code: error.code, index: error.index, operation: error.operation },
|
||||
});
|
||||
}
|
||||
|
||||
throw new ORPCError("INVALID_PATCH_OPERATIONS", {
|
||||
status: 400,
|
||||
message: error instanceof Error ? error.message : "Failed to apply patch operations",
|
||||
});
|
||||
}
|
||||
|
||||
const [resume] = await db
|
||||
.update(schema.resume)
|
||||
.set({ data: patchedData })
|
||||
.where(
|
||||
and(eq(schema.resume.id, input.id), eq(schema.resume.isLocked, false), eq(schema.resume.userId, input.userId)),
|
||||
)
|
||||
.returning({
|
||||
id: schema.resume.id,
|
||||
name: schema.resume.name,
|
||||
slug: schema.resume.slug,
|
||||
tags: schema.resume.tags,
|
||||
data: schema.resume.data,
|
||||
isPublic: schema.resume.isPublic,
|
||||
isLocked: schema.resume.isLocked,
|
||||
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
|
||||
});
|
||||
|
||||
return resume;
|
||||
},
|
||||
|
||||
setLocked: async (input: { id: string; userId: string; isLocked: boolean }) => {
|
||||
await db
|
||||
.update(schema.resume)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { JsonPatchError, Operation } from "fast-json-patch";
|
||||
import jsonpatch from "fast-json-patch";
|
||||
import { type ResumeData, resumeDataSchema } from "@/schema/resume/data";
|
||||
|
||||
export type { Operation };
|
||||
|
||||
/**
|
||||
* A structured error thrown when a JSON Patch operation fails.
|
||||
* Contains only the relevant details -- never the full document tree.
|
||||
*/
|
||||
export class ResumePatchError extends Error {
|
||||
/** The error code from `fast-json-patch`, e.g. `TEST_OPERATION_FAILED`. */
|
||||
code: string;
|
||||
/** The zero-based index of the failing operation in the operations array. */
|
||||
index: number;
|
||||
/** The operation object that caused the failure. */
|
||||
operation: Operation;
|
||||
|
||||
constructor(code: string, message: string, index: number, operation: Operation) {
|
||||
super(message);
|
||||
this.name = "ResumePatchError";
|
||||
this.code = code;
|
||||
this.index = index;
|
||||
this.operation = operation;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable messages for each `fast-json-patch` error code.
|
||||
* These are returned to the API consumer instead of the raw library output.
|
||||
*/
|
||||
const patchErrorMessages: Record<string, string> = {
|
||||
SEQUENCE_NOT_AN_ARRAY: "Patch sequence must be an array.",
|
||||
OPERATION_NOT_AN_OBJECT: "Operation is not an object.",
|
||||
OPERATION_OP_INVALID: "Operation `op` property is not one of the operations defined in RFC 6902.",
|
||||
OPERATION_PATH_INVALID: "Operation `path` property is not a valid JSON Pointer string.",
|
||||
OPERATION_FROM_REQUIRED: "Operation `from` property is required for `move` and `copy` operations.",
|
||||
OPERATION_VALUE_REQUIRED: "Operation `value` property is required for `add`, `replace`, and `test` operations.",
|
||||
OPERATION_VALUE_CANNOT_CONTAIN_UNDEFINED:
|
||||
"Operation `value` contains an `undefined` value, which is not valid in JSON.",
|
||||
OPERATION_PATH_CANNOT_ADD: "Cannot perform an `add` operation at the desired path.",
|
||||
OPERATION_PATH_UNRESOLVABLE: "Cannot perform the operation at a path that does not exist.",
|
||||
OPERATION_FROM_UNRESOLVABLE: "Cannot perform the operation from a path that does not exist.",
|
||||
OPERATION_PATH_ILLEGAL_ARRAY_INDEX: "Array index in path must be an unsigned base-10 integer.",
|
||||
OPERATION_VALUE_OUT_OF_BOUNDS: "The specified array index is greater than the number of elements in the array.",
|
||||
TEST_OPERATION_FAILED: "Test operation failed -- the value at the given path did not match the expected value.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether an error is a `JsonPatchError` from `fast-json-patch`.
|
||||
* The library doesn't export the class directly, so we duck-type it.
|
||||
*/
|
||||
function isJsonPatchError(error: unknown): error is JsonPatchError {
|
||||
return error instanceof Error && "index" in error && "operation" in error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a `JsonPatchError` into a clean `ResumePatchError` that omits the document tree.
|
||||
*/
|
||||
function toResumePatchError(error: JsonPatchError): ResumePatchError {
|
||||
const code = error.name;
|
||||
const message = patchErrorMessages[code] ?? error.message;
|
||||
const index = error.index ?? 0;
|
||||
const operation = error.operation as Operation;
|
||||
|
||||
return new ResumePatchError(code, message, index, operation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an array of JSON Patch (RFC 6902) operations to a `ResumeData` object.
|
||||
*
|
||||
* This function validates the operations before applying them, then validates the
|
||||
* resulting document against the `resumeDataSchema` to ensure the patched data is
|
||||
* still a valid resume.
|
||||
*
|
||||
* The original `data` object is not mutated; a deep clone is created internally.
|
||||
*
|
||||
* @see https://docs.rxresu.me/guides/using-the-patch-api — for usage examples and API details.
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc6902 — JSON Patch specification.
|
||||
*
|
||||
* @param data - The current resume data to patch.
|
||||
* @param operations - An array of JSON Patch operations to apply.
|
||||
* @returns The patched and validated `ResumeData` object.
|
||||
* @throws {ResumePatchError} If the operations are structurally invalid or target non-existent paths.
|
||||
* @throws {ResumePatchError} If a `test` operation does not match.
|
||||
* @throws {Error} If the patched document does not conform to the `ResumeData` schema.
|
||||
*/
|
||||
export function applyResumePatches(data: ResumeData, operations: Operation[]): ResumeData {
|
||||
// Validate operations structurally before applying.
|
||||
const validationError = jsonpatch.validate(operations, data);
|
||||
if (validationError) throw toResumePatchError(validationError);
|
||||
|
||||
// Apply operations. applyPatch throws on `test` failures.
|
||||
let patched: ResumeData;
|
||||
|
||||
try {
|
||||
const result = jsonpatch.applyPatch(data, operations, false, false);
|
||||
patched = result.newDocument;
|
||||
} catch (error: unknown) {
|
||||
if (isJsonPatchError(error)) throw toResumePatchError(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Validate the result still conforms to ResumeData.
|
||||
const parsed = resumeDataSchema.safeParse(patched);
|
||||
if (!parsed.success) throw new Error(`Patch produced invalid resume data: ${parsed.error.message}`);
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
Reference in New Issue
Block a user