2026-03-27 12:27:57 +08:00
import { readFileSync } from "node:fs" ;
import { dirname , resolve } from "node:path" ;
import { fileURLToPath } from "node:url" ;
2026-07-03 19:32:25 +08:00
import scalarPlugin , { type FastifyApiReferenceOptions } from "@scalar/fastify-api-reference" ;
2026-06-29 22:35:05 +08:00
import { SECTIONS , TOOLS , toolSection } from "@snapotter/shared" ;
2026-03-27 12:27:57 +08:00
import type { FastifyInstance } from "fastify" ;
2026-03-27 12:58:38 +08:00
import yaml from "js-yaml" ;
2026-03-27 12:27:57 +08:00
const __dirname = dirname ( fileURLToPath ( import . meta . url ));
2026-03-27 12:58:38 +08:00
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 [] = [];
2026-07-06 08:09:22 +08:00
const customModes : Record < string , string > = {
ocr : "sync-json" ,
"content-aware-resize" : "sync" ,
"passport-photo" : "two-phase" ,
};
2026-03-27 12:58:38 +08:00
lines . push ( `# ${ spec . info . title } ` );
lines . push ( "" );
lines . push (
2026-07-06 08:09:22 +08:00
"> Self-hosted file processing API with 241 catalog tool routes across image, video, audio, document, and file workflows. Convert, compress, edit, transcribe, OCR, and more." ,
2026-03-27 12:58:38 +08:00
);
lines . push ( "" );
2026-07-06 08:09:22 +08:00
lines . push ( "Base URL: `/api/v1`" );
lines . push ( "" );
2026-03-27 12:58:38 +08:00
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 || "" } ` );
}
2026-06-29 22:35:05 +08:00
lines . push ( "" );
lines . push ( "## Tools" );
for ( const section of SECTIONS ) {
const tools = TOOLS . filter (( tool ) => toolSection ( tool ) === section . id );
lines . push ( `- ${ section . name } ( ${ tools . length } tools)` );
for ( const tool of tools ) {
2026-07-06 08:09:22 +08:00
const mode = customModes [ tool . id ] ?? ( tool . executionHint === "long" ? "async" : "sync" );
2026-06-29 22:35:05 +08:00
lines . push ( ` - ${ tool . name } - ${ tool . description } ( ${ tool . id } , ${ mode } )` );
}
}
2026-03-27 12:58:38 +08:00
lines . push ( "" );
lines . push ( "## Authentication" );
2026-06-29 22:35:05 +08:00
lines . push ( "- Session token via `POST /api/auth/login` -> `Authorization: Bearer <token>`" );
lines . push ( "- API key (prefixed `si_`) -> `Authorization: Bearer si_...`" );
2026-07-06 08:09:22 +08:00
lines . push (
"- MFA login challenges return `requiresMfa` and must be completed through `/api/auth/mfa/complete`." ,
);
lines . push ( "" );
lines . push ( "## Processing Contract" );
lines . push (
"- Tool requests use `multipart/form-data` with `file`, optional JSON `settings`, optional `clientJobId`, and optional `fileId`." ,
);
lines . push (
"- Fast tools usually return `200` with `jobId`, `downloadUrl`, `originalSize`, and `processedSize`." ,
);
lines . push (
"- Any queued tool can return `202` with `jobId` and `async: true` when it is long-running or exceeds the synchronous wait window." ,
);
lines . push (
'- Progress streams from `GET /api/v1/jobs/:jobId/progress` as SSE frames with `type: "single"` or `type: "batch"`.' ,
);
lines . push (
"- Missing AI bundles return `501` with code `FEATURE_NOT_INSTALLED`, feature id, feature name, and estimated size." ,
);
lines . push ( "" );
lines . push ( "## Surrounding APIs" );
lines . push ( "- Auth: local login, OIDC, SAML, MFA, user administration, sessions." );
lines . push (
"- Files: upload, library versions, downloads, thumbnails, file previews, URL import." ,
);
lines . push (
"- Workflows: batch routes, pipeline execute/save/list/delete, job cancel and progress." ,
);
lines . push (
"- Admin: health, readiness, metrics, log level, support bundle, usage, backup status, feature bundles." ,
);
lines . push (
"- Enterprise: audit export, config import/export, IP allowlist, legal hold, SCIM, SIEM, webhooks, GDPR lifecycle, upgrade checks." ,
);
2026-03-27 12:58:38 +08:00
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 , []);
2026-04-14 22:15:37 +08:00
tagGroups . get ( tag ) ? . push ({ method : method.toUpperCase (), path , op });
2026-03-27 12:58:38 +08:00
}
}
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" );
}
2026-03-27 12:27:57 +08:00
export async function docsRoutes ( app : FastifyInstance ) : Promise < void > {
const specPath = resolve ( __dirname , "../openapi.yaml" );
const specContent = readFileSync ( specPath , "utf-8" );
2026-03-27 12:58:38 +08:00
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 );
});
2026-03-27 12:27:57 +08:00
app . get ( "/api/v1/openapi.yaml" , async ( _request , reply ) => {
reply . type ( "text/yaml" ). send ( specContent );
});
2026-07-03 19:32:25 +08:00
// Scalar's "Ask AI" (Agent Scalar), "Generate MCP", "Open API Client", and
// the Configure/Share/Deploy toolbar are all Scalar cloud features: they
// upload or link the OpenAPI document to scalar.com, which the docs CSP
// blocks by design (self-hosted docs make no external calls). Hide them
// instead of shipping dead UI. `agent` is a source-level key the plugin's
// configuration type doesn't list yet, hence the widened type.
const configuration : NonNullable < FastifyApiReferenceOptions [ "configuration" ] > & {
agent ?: { disabled? : boolean };
} = {
content : specContent ,
pageTitle : "SnapOtter API Reference" ,
agent : { disabled : true },
mcp : { disabled : true },
hideClientButton : true ,
showDeveloperTools : "never" ,
theme : "default" ,
2026-07-04 13:46:52 +08:00
// Scalar's default typography loads Inter/JetBrains Mono from
// fonts.scalar.com at page load. Disable it and pin both font variables
// to local system stacks so the docs page makes no third-party requests
// (the docs CSP font-src is 'self' data: accordingly).
withDefaultFonts : false ,
2026-07-03 19:32:25 +08:00
customCss : `
2026-03-27 12:27:57 +08:00
:root {
--scalar-color-1: #09090b;
--scalar-color-2: #3f3f46;
--scalar-color-3: #71717a;
--scalar-color-accent: #2563eb;
--scalar-background-1: #ffffff;
--scalar-background-2: #f4f4f5;
--scalar-background-3: #e4e4e7;
--scalar-border-color: #e4e4e7;
--scalar-font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
2026-07-04 13:46:52 +08:00
--scalar-font-code: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
2026-03-27 12:27:57 +08:00
}
2026-07-03 19:32:25 +08:00
/* Hide the "Powered by Scalar" sidebar footer link. Scalar exposes no
config flag for it (unlike the cloud buttons disabled above). */
a[href^="https://www.scalar.com"],
a[href^="https://scalar.com"] {
display: none !important;
}
2026-03-27 12:27:57 +08:00
` ,
2026-07-03 19:32:25 +08:00
hideDownloadButton : false ,
hideTestRequestButton : true ,
hiddenClients : true ,
};
await app . register ( scalarPlugin , {
routePrefix : "/api/docs" ,
configuration ,
2026-03-27 12:27:57 +08:00
});
}