docs(api): describe cover letter endpoints (#3509)

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
Emanuele Tonello
2026-09-17 00:13:21 +02:00
committed by GitHub
co-authored by Amruth Pillai
parent 232f48578b
commit fbf1f8fbac
4 changed files with 11461 additions and 10807 deletions
+51
View File
@@ -1,3 +1,4 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it, vi } from "vitest";
import z from "zod";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
@@ -16,6 +17,11 @@ type GeneratedSpecView = {
Record<
string,
{
tags?: string[];
operationId?: string;
summary?: string;
description?: string;
responses?: Record<string, { description?: string }>;
requestBody?: {
content?: Record<string, { schema?: unknown }>;
};
@@ -72,6 +78,51 @@ function findImpossibleRequestSchemas(spec: GeneratedSpecView) {
}
describe("generateOpenApiSpec", () => {
it("documents all cover-letter procedures with REST metadata", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
const expected = [
["get", "/cover-letters", "listCoverLetters", "List cover letters", "200"],
["get", "/cover-letters/{id}", "getCoverLetter", "Get cover letter by ID", "200"],
["post", "/cover-letters", "createCoverLetter", "Create a cover letter", "200"],
["put", "/cover-letters/{id}", "updateCoverLetter", "Update a cover letter", "200"],
["post", "/cover-letters/{id}/refresh-style", "refreshCoverLetterStyle", "Refresh cover letter style", "200"],
["post", "/cover-letters/{id}/duplicate", "duplicateCoverLetter", "Duplicate a cover letter", "200"],
["delete", "/cover-letters/{id}", "deleteCoverLetter", "Delete a cover letter", "200"],
["post", "/cover-letters/from-resume", "copyEmbeddedCoverLetter", "Copy an embedded cover letter", "200"],
["get", "/cover-letters/{id}/export", "exportCoverLetter", "Export a cover letter", "200"],
["post", "/cover-letters/import", "importCoverLetter", "Import a cover letter", "200"],
] as const;
for (const [method, path, operationId, summary, successStatus] of expected) {
const operation = spec.paths?.[path]?.[method];
expect(operation).toMatchObject({
tags: ["Cover Letters"],
operationId,
summary,
description: expect.any(String),
responses: { [successStatus]: { description: expect.any(String) } },
});
}
});
it("keeps published cover-letter operations in sync with the runtime spec", async () => {
const published = JSON.parse(
await readFile(new URL("../../../../docs/spec.json", import.meta.url), "utf8"),
) as GeneratedSpecView;
const runtime = await generateSpec();
const coverLetterPaths = (spec: GeneratedSpecView) =>
Object.fromEntries(
Object.entries(spec.paths ?? {}).filter(
([path]) => path.startsWith("/cover-letters") || path.startsWith("/coverLetters/"),
),
);
const publishedPaths = coverLetterPaths(published);
const runtimePaths = coverLetterPaths(runtime as GeneratedSpecView);
expect(Object.keys(publishedPaths).sort()).toEqual(Object.keys(runtimePaths).sort());
expect(publishedPaths).toEqual(runtimePaths);
});
it("uses caller-provided application URL and version", async () => {
const spec = await generateSpec();
+40
View File
@@ -61,3 +61,43 @@ description: "Create Reactive Resume API keys, authenticate REST requests with b
</Step>
</Steps>
## Cover-letter REST endpoint migration
Cover-letter REST operations now use `/cover-letters` resource paths and HTTP methods instead of the automatically generated `/coverLetters/*` POST endpoints. This is a **breaking change for REST clients**: the old REST URLs are no longer served and return `404`. There are no compatibility aliases.
All paths below are relative to `/api/openapi` on your instance. Authentication with `x-api-key` is unchanged.
| Previous endpoint | Replacement endpoint |
| --- | --- |
| `POST /coverLetters/list` | `GET /cover-letters` |
| `POST /coverLetters/getById` | `GET /cover-letters/{id}` |
| `POST /coverLetters/create` | `POST /cover-letters` |
| `POST /coverLetters/update` | `PUT /cover-letters/{id}` |
| `POST /coverLetters/refreshStyle` | `POST /cover-letters/{id}/refresh-style` |
| `POST /coverLetters/duplicate` | `POST /cover-letters/{id}/duplicate` |
| `POST /coverLetters/delete` | `DELETE /cover-letters/{id}` |
| `POST /coverLetters/copyEmbedded` | `POST /cover-letters/from-resume` |
| `POST /coverLetters/export` | `GET /cover-letters/{id}/export` |
| `POST /coverLetters/import` | `POST /cover-letters/import` |
Update request inputs as well as the URL and method:
- Move `id` from the JSON body into the URL path wherever `{id}` appears. URL-encode the ID as a single path segment.
- For listing, send `search`, `resumeId`, `applicationId`, `limit`, and `offset` as query parameters, not a JSON body. The get-by-ID and export operations also have no request body.
- Keep the remaining inputs as JSON for POST, PUT, and DELETE requests, with `Content-Type: application/json`. In particular, `expectedRevision` remains required in the JSON body for update, refresh-style, and delete; refreshing style also requires `resumeId`.
- Create, copy-from-resume, and import keep their existing JSON inputs. Update still modifies only the supplied fields; it does not replace the entire document.
- Successful operations return `200`, including create and delete. Delete has an empty response body; do not expect `204` or parse a JSON document from it. Other response shapes are unchanged.
For example, list cover letters with query parameters:
```bash
curl --get "https://rxresu.me/api/openapi/cover-letters" \
-H "x-api-key: YOUR_API_KEY" \
--data-urlencode "search=Engineer" \
--data-urlencode "limit=20"
```
If you generate an SDK, regenerate it from your instance's `/api/openapi/spec.json` after upgrading: operation IDs have changed too (for example, `coverLetters.getById` is now `getCoverLetter`). Self-hosted clients should use the spec from the version they are running.
This migration applies only to REST under `/api/openapi`. Existing oRPC clients under `/api/rpc` continue to use the same `coverLetters.*` procedure names and RPC protocol; do not apply the REST path or payload changes to them.
+11279 -10807
View File
File diff suppressed because it is too large Load Diff
@@ -4,42 +4,133 @@ import { coverLetterService } from "./service";
export const coverLettersRouter = {
list: protectedProcedure
.route({
method: "GET",
path: "/cover-letters",
tags: ["Cover Letters"],
operationId: "listCoverLetters",
summary: "List cover letters",
description:
"Returns the authenticated user's saved cover letters, optionally filtered by search, resume, or application.",
successDescription: "A paginated list of cover letters.",
})
.input(coverLetterDto.list.input)
.output(coverLetterDto.list.output)
.handler(({ context, input }) => coverLetterService.list({ ...input, userId: context.user.id })),
getById: protectedProcedure
.route({
method: "GET",
path: "/cover-letters/{id}",
tags: ["Cover Letters"],
operationId: "getCoverLetter",
summary: "Get cover letter by ID",
description: "Returns a single saved cover letter belonging to the authenticated user.",
successDescription: "The cover letter.",
})
.input(coverLetterDto.getById.input)
.output(coverLetterDto.getById.output)
.handler(({ context, input }) => coverLetterService.getById({ ...input, userId: context.user.id })),
create: protectedProcedure
.route({
method: "POST",
path: "/cover-letters",
tags: ["Cover Letters"],
operationId: "createCoverLetter",
summary: "Create a cover letter",
description: "Creates a saved cover letter, optionally linked to a resume or job application.",
successDescription: "The newly created cover letter.",
})
.input(coverLetterDto.create.input)
.output(coverLetterDto.create.output)
.handler(({ context, input }) => coverLetterService.create({ ...input, userId: context.user.id })),
update: protectedProcedure
.route({
method: "PUT",
path: "/cover-letters/{id}",
tags: ["Cover Letters"],
operationId: "updateCoverLetter",
summary: "Update a cover letter",
description: "Updates the supplied fields of a saved cover letter using its expected revision.",
successDescription: "The updated cover letter.",
})
.input(coverLetterDto.update.input)
.output(coverLetterDto.update.output)
.handler(({ context, input }) => coverLetterService.update({ ...input, userId: context.user.id })),
refreshStyle: protectedProcedure
.route({
method: "POST",
path: "/cover-letters/{id}/refresh-style",
tags: ["Cover Letters"],
operationId: "refreshCoverLetterStyle",
summary: "Refresh cover letter style",
description: "Refreshes a cover letter's style from a selected resume while preserving its content and template.",
successDescription: "The cover letter with refreshed style.",
})
.input(coverLetterDto.refreshStyle.input)
.output(coverLetterDto.refreshStyle.output)
.handler(({ context, input }) => coverLetterService.refreshStyle({ ...input, userId: context.user.id })),
duplicate: protectedProcedure
.route({
method: "POST",
path: "/cover-letters/{id}/duplicate",
tags: ["Cover Letters"],
operationId: "duplicateCoverLetter",
summary: "Duplicate a cover letter",
description: "Creates a copy of an existing saved cover letter, optionally with a new name.",
successDescription: "The duplicated cover letter.",
})
.input(coverLetterDto.duplicate.input)
.output(coverLetterDto.duplicate.output)
.handler(({ context, input }) => coverLetterService.duplicate({ ...input, userId: context.user.id })),
delete: protectedProcedure
.route({
method: "DELETE",
path: "/cover-letters/{id}",
tags: ["Cover Letters"],
operationId: "deleteCoverLetter",
summary: "Delete a cover letter",
description: "Permanently deletes a saved cover letter using its expected revision.",
successDescription: "The cover letter was deleted successfully.",
})
.input(coverLetterDto.delete.input)
.output(coverLetterDto.delete.output)
.handler(({ context, input }) => coverLetterService.delete({ ...input, userId: context.user.id })),
copyEmbedded: protectedProcedure
.route({
method: "POST",
path: "/cover-letters/from-resume",
tags: ["Cover Letters"],
operationId: "copyEmbeddedCoverLetter",
summary: "Copy an embedded cover letter",
description: "Creates a saved cover letter by copying an embedded cover-letter item from a resume.",
successDescription: "The newly created cover letter.",
})
.input(coverLetterDto.copyEmbedded.input)
.output(coverLetterDto.copyEmbedded.output)
.handler(({ context, input }) => coverLetterService.copyEmbedded({ ...input, userId: context.user.id })),
export: protectedProcedure
.route({
method: "GET",
path: "/cover-letters/{id}/export",
tags: ["Cover Letters"],
operationId: "exportCoverLetter",
summary: "Export a cover letter",
description: "Exports a saved cover letter as a standalone Reactive Resume cover-letter document.",
successDescription: "The exported cover letter document.",
})
.input(coverLetterDto.export.input)
.output(coverLetterDto.export.output)
.handler(({ context, input }) => coverLetterService.export({ ...input, userId: context.user.id })),
import: protectedProcedure
.route({
method: "POST",
path: "/cover-letters/import",
tags: ["Cover Letters"],
operationId: "importCoverLetter",
summary: "Import a cover letter",
description: "Creates a saved cover letter from an exported Reactive Resume cover-letter document.",
successDescription: "The imported cover letter.",
})
.input(coverLetterDto.import.input)
.output(coverLetterDto.import.output)
.handler(({ context, input }) => coverLetterService.import({ ...input, userId: context.user.id })),