mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(api): add llms.txt and llms-full.txt endpoints
Serve LLM-friendly documentation at /llms.txt (index) and /llms-full.txt (full API docs as markdown). Generated from the OpenAPI spec at startup.
This commit is contained in:
@@ -26,6 +26,7 @@
|
|||||||
"drizzle-orm": "^0.38.0",
|
"drizzle-orm": "^0.38.0",
|
||||||
"exif-reader": "^2.0.3",
|
"exif-reader": "^2.0.3",
|
||||||
"fastify": "^5.2.0",
|
"fastify": "^5.2.0",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
"jsqr": "^1.4.0",
|
"jsqr": "^1.4.0",
|
||||||
"p-queue": "^9.1.0",
|
"p-queue": "^9.1.0",
|
||||||
"pdfkit": "^0.18.0",
|
"pdfkit": "^0.18.0",
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/better-sqlite3": "^7.6.0",
|
"@types/better-sqlite3": "^7.6.0",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/pdfkit": "^0.17.5",
|
"@types/pdfkit": "^0.17.5",
|
||||||
"@types/potrace": "^2.1.5",
|
"@types/potrace": "^2.1.5",
|
||||||
|
|||||||
@@ -3,12 +3,159 @@ import { dirname, resolve } from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import scalarPlugin from "@scalar/fastify-api-reference";
|
import scalarPlugin from "@scalar/fastify-api-reference";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
interface PathOperation {
|
||||||
|
tags?: string[];
|
||||||
|
summary?: string;
|
||||||
|
description?: string;
|
||||||
|
security?: Array<Record<string, string[]>>;
|
||||||
|
parameters?: Array<{ name: string; in: string; required?: boolean; schema?: { type: string } }>;
|
||||||
|
requestBody?: { content: Record<string, { schema?: SchemaObject }> };
|
||||||
|
responses?: Record<string, { description?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SchemaObject {
|
||||||
|
type?: string;
|
||||||
|
properties?: Record<string, SchemaObject>;
|
||||||
|
required?: string[];
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenAPISpec {
|
||||||
|
info: { title: string; version: string; description?: string };
|
||||||
|
tags?: Array<{ name: string; description?: string }>;
|
||||||
|
paths: Record<string, Record<string, PathOperation>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPublic(op: PathOperation): boolean {
|
||||||
|
return Array.isArray(op.security) && op.security.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateLlmsTxt(spec: OpenAPISpec): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`# ${spec.info.title}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push(
|
||||||
|
"> Self-hosted image processing API with 33+ tools. Resize, compress, convert, remove backgrounds, upscale, run OCR, and more.",
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("## Docs");
|
||||||
|
lines.push("- [Interactive API Reference](/api/docs): Full interactive API documentation");
|
||||||
|
lines.push("- [OpenAPI Spec](/api/v1/openapi.yaml): OpenAPI 3.1 specification (YAML)");
|
||||||
|
lines.push(
|
||||||
|
"- [Full API Docs (LLM-friendly)](/llms-full.txt): Complete API documentation in plain text",
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("## API Sections");
|
||||||
|
|
||||||
|
for (const tag of spec.tags || []) {
|
||||||
|
const count = Object.values(spec.paths).reduce((n, methods) => {
|
||||||
|
return n + Object.values(methods).filter((op) => op.tags?.[0] === tag.name).length;
|
||||||
|
}, 0);
|
||||||
|
lines.push(`- ${tag.name} (${count} endpoints): ${tag.description || ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push("## Authentication");
|
||||||
|
lines.push("- Session token via `POST /api/auth/login` → `Authorization: Bearer <token>`");
|
||||||
|
lines.push("- API key (prefixed `si_`) → `Authorization: Bearer si_...`");
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateLlmsFullTxt(spec: OpenAPISpec): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`# ${spec.info.title} v${spec.info.version}`);
|
||||||
|
lines.push("");
|
||||||
|
if (spec.info.description) {
|
||||||
|
lines.push(spec.info.description.trim());
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group paths by tag
|
||||||
|
const tagGroups = new Map<string, Array<{ method: string; path: string; op: PathOperation }>>();
|
||||||
|
for (const [path, methods] of Object.entries(spec.paths)) {
|
||||||
|
for (const [method, op] of Object.entries(methods)) {
|
||||||
|
const tag = op.tags?.[0] || "Other";
|
||||||
|
if (!tagGroups.has(tag)) tagGroups.set(tag, []);
|
||||||
|
tagGroups.get(tag)!.push({ method: method.toUpperCase(), path, op });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagOrder = (spec.tags || []).map((t) => t.name);
|
||||||
|
const allTags = [...new Set([...tagOrder, ...tagGroups.keys()])];
|
||||||
|
|
||||||
|
for (const tag of allTags) {
|
||||||
|
const endpoints = tagGroups.get(tag);
|
||||||
|
if (!endpoints) continue;
|
||||||
|
|
||||||
|
const tagInfo = spec.tags?.find((t) => t.name === tag);
|
||||||
|
lines.push(`## ${tag}`);
|
||||||
|
if (tagInfo?.description) lines.push(`${tagInfo.description}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
for (const { method, path, op } of endpoints) {
|
||||||
|
const auth = isPublic(op) ? "(public)" : "(auth required)";
|
||||||
|
lines.push(`### ${method} ${path} ${auth}`);
|
||||||
|
if (op.summary) lines.push(`**${op.summary}**`);
|
||||||
|
if (op.description) lines.push(op.description.trim());
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
if (op.parameters?.length) {
|
||||||
|
lines.push("**Parameters:**");
|
||||||
|
for (const p of op.parameters) {
|
||||||
|
lines.push(
|
||||||
|
`- \`${p.name}\` (${p.in}${p.required ? ", required" : ""}) — ${p.schema?.type || "string"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (op.requestBody) {
|
||||||
|
const contentType = Object.keys(op.requestBody.content)[0];
|
||||||
|
const schema = op.requestBody.content[contentType]?.schema;
|
||||||
|
lines.push(`**Request:** \`${contentType}\``);
|
||||||
|
if (schema?.properties) {
|
||||||
|
for (const [name, prop] of Object.entries(schema.properties)) {
|
||||||
|
const required = schema.required?.includes(name) ? " (required)" : "";
|
||||||
|
const desc = prop.description ? ` — ${prop.description.split("\n")[0]}` : "";
|
||||||
|
lines.push(`- \`${name}\`${required}: ${prop.type || "string"}${desc}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (op.responses) {
|
||||||
|
lines.push("**Responses:**");
|
||||||
|
for (const [code, res] of Object.entries(op.responses)) {
|
||||||
|
lines.push(`- \`${code}\` — ${res.description || ""}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
export async function docsRoutes(app: FastifyInstance): Promise<void> {
|
export async function docsRoutes(app: FastifyInstance): Promise<void> {
|
||||||
const specPath = resolve(__dirname, "../openapi.yaml");
|
const specPath = resolve(__dirname, "../openapi.yaml");
|
||||||
const specContent = readFileSync(specPath, "utf-8");
|
const specContent = readFileSync(specPath, "utf-8");
|
||||||
|
const spec = yaml.load(specContent) as OpenAPISpec;
|
||||||
|
|
||||||
|
const llmsTxt = generateLlmsTxt(spec);
|
||||||
|
const llmsFullTxt = generateLlmsFullTxt(spec);
|
||||||
|
|
||||||
|
app.get("/llms.txt", async (_request, reply) => {
|
||||||
|
reply.type("text/plain; charset=utf-8").send(llmsTxt);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/llms-full.txt", async (_request, reply) => {
|
||||||
|
reply.type("text/plain; charset=utf-8").send(llmsFullTxt);
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/v1/openapi.yaml", async (_request, reply) => {
|
app.get("/api/v1/openapi.yaml", async (_request, reply) => {
|
||||||
reply.type("text/yaml").send(specContent);
|
reply.type("text/yaml").send(specContent);
|
||||||
|
|||||||
Generated
+11
@@ -113,6 +113,9 @@ importers:
|
|||||||
fastify:
|
fastify:
|
||||||
specifier: ^5.2.0
|
specifier: ^5.2.0
|
||||||
version: 5.8.2
|
version: 5.8.2
|
||||||
|
js-yaml:
|
||||||
|
specifier: ^4.1.1
|
||||||
|
version: 4.1.1
|
||||||
jsqr:
|
jsqr:
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
@@ -144,6 +147,9 @@ importers:
|
|||||||
'@types/better-sqlite3':
|
'@types/better-sqlite3':
|
||||||
specifier: ^7.6.0
|
specifier: ^7.6.0
|
||||||
version: 7.6.13
|
version: 7.6.13
|
||||||
|
'@types/js-yaml':
|
||||||
|
specifier: ^4.0.9
|
||||||
|
version: 4.0.9
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
version: 22.19.15
|
version: 22.19.15
|
||||||
@@ -2362,6 +2368,9 @@ packages:
|
|||||||
'@types/hast@3.0.4':
|
'@types/hast@3.0.4':
|
||||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||||
|
|
||||||
|
'@types/js-yaml@4.0.9':
|
||||||
|
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
|
||||||
|
|
||||||
'@types/linkify-it@5.0.0':
|
'@types/linkify-it@5.0.0':
|
||||||
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||||
|
|
||||||
@@ -7464,6 +7473,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
|
|
||||||
|
'@types/js-yaml@4.0.9': {}
|
||||||
|
|
||||||
'@types/linkify-it@5.0.0': {}
|
'@types/linkify-it@5.0.0': {}
|
||||||
|
|
||||||
'@types/markdown-it@14.1.2':
|
'@types/markdown-it@14.1.2':
|
||||||
|
|||||||
Reference in New Issue
Block a user