diff --git a/README.md b/README.md index 73beeb9..f0e1943 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,5 @@ Wiki、创作站和 Python 导出工具。 详见 [项目结构与运行方式](docs/monorepo.md)。 + +业务接口详见 [API 说明](docs/api.md),线上纯文本目录为 `/api?agent_mode=True`。 diff --git a/apps/studio/src/lib/api-documentation.ts b/apps/studio/src/lib/api-documentation.ts new file mode 100644 index 0000000..8be2f43 --- /dev/null +++ b/apps/studio/src/lib/api-documentation.ts @@ -0,0 +1,11 @@ +import type {NextApiRequest,NextApiResponse} from 'next'; +const docs:Record={ + '/studio/api/export/{slug}': '# 导出创作方案\n\n方法:GET\n路径参数:slug,创作方案的稳定标识。\n查询参数:format=markdown 返回 Markdown;其他值或省略时返回带 version:1 的方案 JSON,带附件文件名。\n示例:GET /studio/api/export/{slug}\n错误:404 方案不存在;405 方法不支持。', + '/studio/api/health': '# 创作站健康检查\n\n方法:GET, HEAD\n无业务参数。GET 返回 200 JSON:{"status":"ok","service":"prompt-studio"};HEAD 无正文。\n示例:GET /studio/api/health\n错误:405 方法不支持。', +}; +export function serveApiDocumentation(req:NextApiRequest,res:NextApiResponse,path:string){ + if(!['GET','HEAD'].includes(req.method||'')||typeof req.query.agent_mode!=='string'||req.query.agent_mode.toLowerCase()!=='true')return false; + if(!docs[path])return false; + res.setHeader('Content-Type','text/plain; charset=utf-8');res.setHeader('Cache-Control','no-store');res.status(200); + if(req.method==='HEAD')res.end();else res.send(docs[path]+'\n\n使用 GET 或 HEAD,在原地址加 ?agent_mode=True;已有参数时加 &agent_mode=True。值不区分大小写。动态路径可保留占位符读取说明。业务调用请移除 agent_mode。\nAPI 目录:/api?agent_mode=True\n');return true; +} diff --git a/apps/studio/src/pages/api/export/[slug].ts b/apps/studio/src/pages/api/export/[slug].ts index 1b2451f..a933960 100644 --- a/apps/studio/src/pages/api/export/[slug].ts +++ b/apps/studio/src/pages/api/export/[slug].ts @@ -1,3 +1,4 @@ +import {serveApiDocumentation} from '../../../lib/api-documentation'; import type {NextApiRequest,NextApiResponse} from 'next'; import {ideas} from '../../../content/ideas'; -export default function handler(req:NextApiRequest,res:NextApiResponse){if(req.method!=='GET')return res.status(405).end();const idea=ideas.find(i=>i.slug===req.query.slug);if(!idea)return res.status(404).end();if(req.query.format==='markdown'){res.setHeader('Content-Type','text/markdown; charset=utf-8');return res.send('# '+idea.title+'\n\n'+idea.summary+'\n\n'+idea.slides.map(s=>'## '+s.heading+'\n\n'+s.body).join('\n\n'));}res.setHeader('Content-Disposition',`attachment; filename="${idea.slug}.json"`);return res.json({version:1,...idea});} +export default function handler(req:NextApiRequest,res:NextApiResponse){if(serveApiDocumentation(req,res,'/studio/api/export/{slug}'))return;if(req.method!=='GET')return res.status(405).end();const idea=ideas.find(i=>i.slug===req.query.slug);if(!idea)return res.status(404).end();if(req.query.format==='markdown'){res.setHeader('Content-Type','text/markdown; charset=utf-8');return res.send('# '+idea.title+'\n\n'+idea.summary+'\n\n'+idea.slides.map(s=>'## '+s.heading+'\n\n'+s.body).join('\n\n'));}res.setHeader('Content-Disposition',`attachment; filename="${idea.slug}.json"`);return res.json({version:1,...idea});} diff --git a/apps/studio/src/pages/api/health.ts b/apps/studio/src/pages/api/health.ts index efeacca..4d52af9 100644 --- a/apps/studio/src/pages/api/health.ts +++ b/apps/studio/src/pages/api/health.ts @@ -1,2 +1,3 @@ +import {serveApiDocumentation} from '../../lib/api-documentation'; import type {NextApiRequest,NextApiResponse} from "next"; -export default function handler(req:NextApiRequest,res:NextApiResponse){res.setHeader("Cache-Control","no-store");if(!["GET","HEAD"].includes(req.method||""))return res.status(405).end();return req.method==="HEAD"?res.status(200).end():res.status(200).json({status:"ok",service:"prompt-studio"});} +export default function handler(req:NextApiRequest,res:NextApiResponse){if(serveApiDocumentation(req,res,'/studio/api/health'))return;res.setHeader("Cache-Control","no-store");if(!["GET","HEAD"].includes(req.method||""))return res.status(405).end();return req.method==="HEAD"?res.status(200).end():res.status(200).json({status:"ok",service:"prompt-studio"});} diff --git a/apps/wiki/package.json b/apps/wiki/package.json index 4ec54b4..01aa895 100644 --- a/apps/wiki/package.json +++ b/apps/wiki/package.json @@ -10,7 +10,7 @@ "build": "next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && node scripts/prepare-standalone.mjs", "start": "next start", "typecheck": "next typegen && tsc --noEmit", - "test": "tsx --test tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx", + "test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx", "test:smoke": "node scripts/smoke.mjs", "storage:prepare": "tsx scripts/bootstrap.ts", "content:publish-learning": "tsx scripts/publish-learning-series.ts", diff --git a/apps/wiki/src/lib/api-documentation.ts b/apps/wiki/src/lib/api-documentation.ts new file mode 100644 index 0000000..5921140 --- /dev/null +++ b/apps/wiki/src/lib/api-documentation.ts @@ -0,0 +1,101 @@ +import type {NextApiRequest,NextApiResponse} from 'next'; + +// Read documentation before business validation or storage access. Mutations never enter this branch. +export const apiDocuments:Record={ + "/api/explore-feed": [ + "统一内容流", + "GET", + "查询参数:q(可选,最多 200 字符);category(可选,标签名称或命名空间);cursor(可选,上一页 nextCursor)。", + "JSON:entries 数组与 nextCursor;没有更多结果时 nextCursor 为 null。静态内容仅出现在第一页。", + "GET /api/explore-feed?q=排版", + "503:素材库暂时无法连接。" + ], + "/api/feed": [ + "素材列表", + "GET", + "查询参数:section(images / videos / audio / articles / motion,可选);path(可选,标签 path);q(可选,最多 200 字符);cursor(可选,上一页 nextCursor)。", + "JSON:posts、tags、contributions、nextCursor;每页最多 24 个 post。", + "GET /api/feed?section=images", + "400:未知 section、标签或无效游标。" + ], + "/api/notes/{id}": [ + "读取与编辑笔记", + "GET, PUT", + "路径参数:id,笔记稳定 ID。PUT 使用 application/json,所有字段必填:title(1–200 字符)、description(最多 1000 字符)、markdown(最多 100000 字符)、tags(最多 50 个标签 UUID)、revision(GET 返回的 entry.revision,64 位十六进制)。", + "GET 返回 {entry,tags};PUT 返回更新后的 {entry}。先 GET 获取 revision,再 PUT;冲突后重新读取。", + "GET /api/notes/{id}\nPUT /api/notes/{id}\n{\"title\":\"标题\",\"description\":\"简介\",\"markdown\":\"正文\",\"tags\":[],\"revision\":\"\"}", + "400:输入错误;404:笔记不存在;409:版本冲突。" + ], + "/api/posts": [ + "创建笔记或素材包", + "POST", + "application/json:kind(必填,image / video / audio / article / motion)、title(必填,1–200 字符);slug(可选,最多 120 字符,以小写字母或数字开头,仅含小写字母、数字、连字符);description(可选,最多 1000 字符);text(可选,最多 100000 字符);tags(可选,最多 50 个 UUID);coverAssetId(可选,图片 UUID);assets(可选,最多 100 项,{id: UUID,role: content | caption},role 默认 content)。媒体类型必须引用至少一个对应类型的 content 素材。", + "201 JSON:{id,slug}。引用已有素材;不会在此接口上传二进制文件。", + "POST /api/posts\n{\"kind\":\"article\",\"title\":\"新笔记\",\"text\":\"正文\",\"tags\":[]}", + "400:输入或素材引用不合法;409:slug 已存在。" + ], + "/api/tags": [ + "标签列表与创建", + "GET, POST", + "POST 使用 application/json:name(必填,去掉开头 # 并 trim 后 1–60 字符,不含 #、空白、英文或中文逗号);slug(可选,最多 60 字符,以小写字母或数字开头,仅含小写字母、数字、连字符)。", + "GET 返回标签数组;POST 新建返回 201,名称已存在时复用并返回 200。标签含 id、name、slug、path。", + "POST /api/tags\n{\"name\":\"排版\"}", + "400:输入错误;409:slug 冲突。" + ], + "/api/upload": [ + "上传媒体并创建素材包", + "POST", + "multipart/form-data:kind(必填,image / video / audio);title(必填,1–200 字符);slug(可选,小写字母或数字组成的非空段,以连字符分隔,最多 120 字符);description(可选,最多 100000 字符);tags(可选,UUID 数组的 JSON 字符串,最多 50 项);文件字段名任意,至少一个文件。最多 16 个文件,单个不超过 250 MB,总计不超过 1 GB。图片支持 JPG、PNG、WebP、GIF、AVIF;音频必须为独立声音文件。", + "201 JSON:{id,slug,url,assets},assets 为素材 UUID 数组。视频会自动进入后台加工队列。", + "POST /api/upload\nContent-Type: multipart/form-data\nkind=image\ntitle=示例图片\ntags=[]\nfile=<图片文件>", + "400:输入或媒体不合法;409:slug 冲突;413:上传超过限制。" + ], + "/api/media/{id}": [ + "读取媒体原件或衍生文件", + "GET, HEAD", + "路径参数:id,素材 UUID。查询参数:variant(可选,poster / silent_video / audio_track,读取以 id 为来源的衍生素材)。支持 Range 请求头。", + "二进制媒体,Content-Type 随文件类型变化;200 为完整文件,206 为范围响应,HEAD 无正文。", + "GET /api/media/{id}?variant=poster", + "404:素材不存在或无效 variant;416:请求范围无效。" + ], + "/api/preview": [ + "轻量图片预览", + "GET, HEAD", + "查询参数:source(必填,本站静态栅格图片路径,或 /api/media/,可带 ?variant=poster;作为查询值时必须 URL 编码);width(可选,240 / 480 / 960 / 1280,默认 480)。不接受任意远程图片地址。支持 If-None-Match。", + "WebP 图片;包含 ETag;缓存命中可返回 304;HEAD 无正文。", + "GET /api/preview?source=%2Fapi%2Fmedia%2F&width=480", + "400:参数不合法;404:图片不存在;503:预览暂不可用。" + ], + "/api/skills/street-people": [ + "透明人物规范与上传", + "GET, POST", + "GET 默认返回 JSON 规范,format=md 返回创作指导 Markdown。POST 使用 multipart/form-data,沿用 /api/upload 字段与大小限制;kind 固定为 image,title 默认“街头小人 · 新生成”。图片必须为带 alpha 的 PNG,含 alpha=0 的透明像素及 alpha≥250 的像素。", + "GET 返回生成规范;POST 返回 201 {id,slug,url,assets}。", + "GET /api/skills/street-people\nPOST /api/skills/street-people\nContent-Type: multipart/form-data\nfile=<透明 PNG>", + "400:图片或字段不合法;413:超过上传限制。" + ], + "/api/health": [ + "应用健康检查", + "GET, HEAD", + "无业务参数。", + "GET 返回 JSON:status、service、release;HEAD 仅返回状态和响应头。", + "GET /api/health", + "405:不支持的方法。" + ], + "/api/ready": [ + "依赖就绪检查", + "GET", + "无业务参数。检查 PostgreSQL 和对象存储桶。", + "200 JSON:{\"status\":\"ok\",\"database\":\"ready\",\"objects\":\"ready\"}。", + "GET /api/ready", + "503 JSON:{\"status\":\"unavailable\"}。" + ] +}; +export function serveApiDocumentation(req:NextApiRequest,res:NextApiResponse,path:string){ + if(!['GET','HEAD'].includes(req.method||'')||typeof req.query.agent_mode!=='string'||req.query.agent_mode.toLowerCase()!=='true')return false; + const doc=apiDocuments[path];if(!doc)return false; + const [title,methods,parameters,response,example,errors]=doc; + const text=`# ${title}\n\n地址:${path}\n方法:${methods}\n\n## 参数\n${parameters}\n\n## 响应\n${response}\n\n## 调用示例\n${example}\n\n## 错误\n${errors}\n\n## 读取说明\n使用 GET 或 HEAD,在原地址加 ?agent_mode=True;已有查询参数时加 &agent_mode=True。值不区分大小写。动态路径可使用实际 ID,也可保留占位符读取本接口说明。\n业务调用请移除 agent_mode。POST / PUT 不会被转换为说明请求。写入接口会拒绝 Origin 与 Host 不一致的请求;当前没有 API Key 或登录鉴权。\n\nAPI 目录:/api?agent_mode=True\n`; + res.setHeader('Content-Type','text/plain; charset=utf-8');res.setHeader('Cache-Control','no-store'); + res.status(200);if(req.method==='HEAD')res.end();else res.send(text);return true; +} diff --git a/apps/wiki/src/pages/api/explore-feed.ts b/apps/wiki/src/pages/api/explore-feed.ts index 549f9ae..d03c669 100644 --- a/apps/wiki/src/pages/api/explore-feed.ts +++ b/apps/wiki/src/pages/api/explore-feed.ts @@ -1,3 +1,5 @@ +import {serveApiDocumentation} from '@/lib/api-documentation'; import type {NextApiRequest,NextApiResponse} from 'next'; import {exploreFeed} from '@/lib/explore'; -export default async function handler(req:NextApiRequest,res:NextApiResponse){if(req.method!=='GET')return res.status(405).end();try{return res.json(await exploreFeed(String(req.query.q||'').slice(0,200),String(req.query.category||''),String(req.query.cursor||'')||undefined));}catch{return res.status(503).json({error:'素材库暂时无法连接'});}} +export default async function handler(req:NextApiRequest,res:NextApiResponse){ + if(serveApiDocumentation(req,res,'/api/explore-feed'))return;if(req.method!=='GET')return res.status(405).end();try{return res.json(await exploreFeed(String(req.query.q||'').slice(0,200),String(req.query.category||''),String(req.query.cursor||'')||undefined));}catch{return res.status(503).json({error:'素材库暂时无法连接'});}} diff --git a/apps/wiki/src/pages/api/feed.ts b/apps/wiki/src/pages/api/feed.ts index 115a3d1..1bac43d 100644 --- a/apps/wiki/src/pages/api/feed.ts +++ b/apps/wiki/src/pages/api/feed.ts @@ -1,8 +1,10 @@ +import {serveApiDocumentation} from '@/lib/api-documentation'; import type { NextApiRequest,NextApiResponse } from "next"; import { feed } from "@/lib/library"; import { validSection } from "@/lib/model"; import { apiError } from "@/lib/api"; export default async function handler(req:NextApiRequest,res:NextApiResponse){ + if(serveApiDocumentation(req,res,'/api/feed'))return; if(req.method!=='GET')return res.status(405).end(); try {const section=String(req.query.section||'');if(section&&!validSection(section))return res.status(400).json({error:'未知分类'}); res.setHeader('Cache-Control','no-store');return res.json(await feed({kind:validSection(section)?.kind,path:String(req.query.path||''),cursor:String(req.query.cursor||''),q:String(req.query.q||'').slice(0,200)})); diff --git a/apps/wiki/src/pages/api/health.ts b/apps/wiki/src/pages/api/health.ts index a8acc74..7f74171 100644 --- a/apps/wiki/src/pages/api/health.ts +++ b/apps/wiki/src/pages/api/health.ts @@ -1,7 +1,9 @@ +import {serveApiDocumentation} from '@/lib/api-documentation'; import type { NextApiRequest, NextApiResponse } from "next"; import { healthResponse } from "@/lib/health"; export default function handler(req: NextApiRequest, res: NextApiResponse) { + if(serveApiDocumentation(req,res,'/api/health'))return; const { statusCode, body } = healthResponse(req.method, process.env.RELEASE_SHA); res.setHeader("Cache-Control", "no-store"); if (statusCode === 405) res.setHeader("Allow", "GET, HEAD"); diff --git a/apps/wiki/src/pages/api/index.ts b/apps/wiki/src/pages/api/index.ts new file mode 100644 index 0000000..5c5e7c4 --- /dev/null +++ b/apps/wiki/src/pages/api/index.ts @@ -0,0 +1,14 @@ +import type {NextApiRequest,NextApiResponse} from 'next'; +import {apiDocuments} from '@/lib/api-documentation'; +export default function handler(req:NextApiRequest,res:NextApiResponse){ + if(!['GET','HEAD'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD');return res.status(405).end();} + const entries=[...Object.entries(apiDocuments).map(([path,doc])=>({path,title:doc[0],methods:doc[1],documentation:path+'?agent_mode=True'})), + {path:'/studio/api/export/{slug}',title:'导出创作方案',methods:'GET',documentation:'/studio/api/export/{slug}?agent_mode=True'}, + {path:'/studio/api/health',title:'创作站健康检查',methods:'GET, HEAD',documentation:'/studio/api/health?agent_mode=True'}]; + res.setHeader('Cache-Control','no-store'); + const plain=typeof req.query.agent_mode==='string'&&req.query.agent_mode.toLowerCase()==='true'; + res.setHeader('Content-Type',plain?'text/plain; charset=utf-8':'application/json; charset=utf-8'); + if(req.method==='HEAD')return res.status(200).end(); + if(plain)return res.send('# PROMPT 业务 API\n\n各接口使用 GET + agent_mode=True 读取说明;已有参数时用 & 拼接。动态路径允许保留占位符。业务调用请移除该参数。\n\n'+entries.map(e=>`- ${e.title}(${e.methods}):${e.documentation}`).join('\n')+'\n'); + return res.json({entries}); +} diff --git a/apps/wiki/src/pages/api/media/[id].ts b/apps/wiki/src/pages/api/media/[id].ts index cbd56a2..0c2a8fc 100644 --- a/apps/wiki/src/pages/api/media/[id].ts +++ b/apps/wiki/src/pages/api/media/[id].ts @@ -1,8 +1,10 @@ +import {serveApiDocumentation} from '@/lib/api-documentation'; import type { NextApiRequest,NextApiResponse } from "next"; import type { Readable } from "node:stream"; import { db } from "@/lib/db"; import { getObject } from "@/lib/storage"; export default async function handler(req:NextApiRequest,res:NextApiResponse){ + if(serveApiDocumentation(req,res,'/api/media/{id}'))return; if(!['GET','HEAD'].includes(req.method||''))return res.status(405).end(); if(!/^[0-9a-f-]{36}$/.test(String(req.query.id)))return res.status(404).end(); try{ diff --git a/apps/wiki/src/pages/api/notes/[id].ts b/apps/wiki/src/pages/api/notes/[id].ts index 652625c..f2266b1 100644 --- a/apps/wiki/src/pages/api/notes/[id].ts +++ b/apps/wiki/src/pages/api/notes/[id].ts @@ -1,9 +1,11 @@ +import {serveApiDocumentation} from '@/lib/api-documentation'; import type {NextApiRequest,NextApiResponse} from 'next'; import {getEntry} from '@/lib/explore'; import {tags} from '@/lib/library'; import {noteEditInput,saveNote,NoteEditConflict} from '@/lib/note-edits'; import {sameOrigin,apiError} from '@/lib/api'; export default async function handler(req:NextApiRequest,res:NextApiResponse){ + if(serveApiDocumentation(req,res,'/api/notes/{id}'))return; res.setHeader('Cache-Control','no-store'); if(!['GET','PUT'].includes(req.method||'')){res.setHeader('Allow','GET, PUT');return res.status(405).end();} try{ diff --git a/apps/wiki/src/pages/api/posts/index.ts b/apps/wiki/src/pages/api/posts/index.ts index e2e14f3..3cea449 100644 --- a/apps/wiki/src/pages/api/posts/index.ts +++ b/apps/wiki/src/pages/api/posts/index.ts @@ -1,7 +1,9 @@ +import {serveApiDocumentation} from '@/lib/api-documentation'; import type { NextApiRequest,NextApiResponse } from "next"; import { createPost,postInput } from "@/lib/write"; import { sameOrigin,apiError } from "@/lib/api"; export default async function handler(req:NextApiRequest,res:NextApiResponse){ + if(serveApiDocumentation(req,res,'/api/posts'))return; if(req.method!=='POST')return res.status(405).end(); try{sameOrigin(req);return res.status(201).json(await createPost(postInput.parse(req.body)));}catch(e){return apiError(res,e);} } diff --git a/apps/wiki/src/pages/api/preview.ts b/apps/wiki/src/pages/api/preview.ts index df82861..0ea275c 100644 --- a/apps/wiki/src/pages/api/preview.ts +++ b/apps/wiki/src/pages/api/preview.ts @@ -1,6 +1,8 @@ +import {serveApiDocumentation} from '@/lib/api-documentation'; import type {NextApiRequest,NextApiResponse} from 'next'; import {db} from '@/lib/db'; import {getObject} from '@/lib/storage'; import {previewWidth,cachedPreview,loadStaticPreview} from '@/lib/preview'; -export default async function handler(req:NextApiRequest,res:NextApiResponse){if(!['GET','HEAD'].includes(req.method||''))return res.status(405).end();const width=previewWidth(req.query.width);if(!width||typeof req.query.source!=='string')return res.status(400).end();try{const source=req.query.source;let input:{identity:string;load:()=>Promise};if(source.startsWith('/api/media/')){const match=source.match(/^\/api\/media\/([0-9a-f-]{36})(?:\?variant=(poster))?$/);if(!match)return res.status(400).end();const result=match[2]?await db().query('SELECT storage_key,mime_type FROM assets WHERE source_asset_id=$1 AND derivation=$2',[match[1],match[2]]):await db().query('SELECT storage_key,mime_type FROM assets WHERE id=$1',[match[1]]);const row=result.rows[0];if(!row?.storage_key||!row.mime_type?.startsWith('image/'))return res.status(404).end();input={identity:row.storage_key,load:async()=>{const object=await getObject(row.storage_key);if((object.ContentLength||0)>64*1024*1024)throw Error('Image too large');const chunks:Buffer[]=[];let length=0;for await(const chunk of object.Body as AsyncIterable){length+=chunk.length;if(length>64*1024*1024)throw Error('Image too large');chunks.push(Buffer.from(chunk));}return Buffer.concat(chunks);}};}else input=await loadStaticPreview(source);const {bytes,etag}=await cachedPreview(input.identity,width,input.load);res.setHeader('ETag',etag);res.setHeader('Cache-Control','public, max-age=3600');res.setHeader('Content-Type','image/webp');res.setHeader('X-Content-Type-Options','nosniff');if(req.headers['if-none-match']===etag)return res.status(304).end();res.setHeader('Content-Length',bytes.length);return req.method==='HEAD'?res.end():res.send(bytes);}catch(e){const message=(e as Error).message;if(message.includes('Invalid source'))return res.status(400).end();if((e as NodeJS.ErrnoException).code==='ENOENT')return res.status(404).end();res.setHeader('Retry-After','5');return res.status(503).end();}} +export default async function handler(req:NextApiRequest,res:NextApiResponse){ + if(serveApiDocumentation(req,res,'/api/preview'))return;if(!['GET','HEAD'].includes(req.method||''))return res.status(405).end();const width=previewWidth(req.query.width);if(!width||typeof req.query.source!=='string')return res.status(400).end();try{const source=req.query.source;let input:{identity:string;load:()=>Promise};if(source.startsWith('/api/media/')){const match=source.match(/^\/api\/media\/([0-9a-f-]{36})(?:\?variant=(poster))?$/);if(!match)return res.status(400).end();const result=match[2]?await db().query('SELECT storage_key,mime_type FROM assets WHERE source_asset_id=$1 AND derivation=$2',[match[1],match[2]]):await db().query('SELECT storage_key,mime_type FROM assets WHERE id=$1',[match[1]]);const row=result.rows[0];if(!row?.storage_key||!row.mime_type?.startsWith('image/'))return res.status(404).end();input={identity:row.storage_key,load:async()=>{const object=await getObject(row.storage_key);if((object.ContentLength||0)>64*1024*1024)throw Error('Image too large');const chunks:Buffer[]=[];let length=0;for await(const chunk of object.Body as AsyncIterable){length+=chunk.length;if(length>64*1024*1024)throw Error('Image too large');chunks.push(Buffer.from(chunk));}return Buffer.concat(chunks);}};}else input=await loadStaticPreview(source);const {bytes,etag}=await cachedPreview(input.identity,width,input.load);res.setHeader('ETag',etag);res.setHeader('Cache-Control','public, max-age=3600');res.setHeader('Content-Type','image/webp');res.setHeader('X-Content-Type-Options','nosniff');if(req.headers['if-none-match']===etag)return res.status(304).end();res.setHeader('Content-Length',bytes.length);return req.method==='HEAD'?res.end():res.send(bytes);}catch(e){const message=(e as Error).message;if(message.includes('Invalid source'))return res.status(400).end();if((e as NodeJS.ErrnoException).code==='ENOENT')return res.status(404).end();res.setHeader('Retry-After','5');return res.status(503).end();}} export const config={api:{responseLimit:false}}; diff --git a/apps/wiki/src/pages/api/ready.ts b/apps/wiki/src/pages/api/ready.ts index 6385d5e..59d9b63 100644 --- a/apps/wiki/src/pages/api/ready.ts +++ b/apps/wiki/src/pages/api/ready.ts @@ -1,8 +1,10 @@ +import {serveApiDocumentation} from '@/lib/api-documentation'; import type { NextApiRequest,NextApiResponse } from 'next'; import { db } from '@/lib/db'; import { storage,bucket } from '@/lib/storage'; import { HeadBucketCommand } from '@aws-sdk/client-s3'; export default async function handler(req:NextApiRequest,res:NextApiResponse){ + if(serveApiDocumentation(req,res,'/api/ready'))return; if(req.method!=='GET')return res.status(405).end(); res.setHeader('Cache-Control','no-store'); try{await db().query('SELECT 1 FROM schema_migrations LIMIT 1');await storage().send(new HeadBucketCommand({Bucket:bucket()}));return res.json({status:'ok',database:'ready',objects:'ready'});}catch{return res.status(503).json({status:'unavailable'});} diff --git a/apps/wiki/src/pages/api/skills/street-people.ts b/apps/wiki/src/pages/api/skills/street-people.ts index 1bbe1d7..293f17f 100644 --- a/apps/wiki/src/pages/api/skills/street-people.ts +++ b/apps/wiki/src/pages/api/skills/street-people.ts @@ -1,3 +1,4 @@ +import {serveApiDocumentation} from '@/lib/api-documentation'; import type {NextApiRequest,NextApiResponse} from "next"; import {upload} from "../upload"; import {streetPeople} from "@/lib/street-people"; @@ -5,6 +6,7 @@ import {guidanceMarkdown} from "@/content/skills/guidance"; import {validateCharacter} from "@/lib/character-image"; export const config={api:{bodyParser:false}}; export default async function handler(req:NextApiRequest,res:NextApiResponse){ + if(serveApiDocumentation(req,res,'/api/skills/street-people'))return; res.setHeader("Allow","GET, POST"); if(req.method==="GET"){ res.setHeader("Cache-Control","public, max-age=300"); diff --git a/apps/wiki/src/pages/api/tags.ts b/apps/wiki/src/pages/api/tags.ts index b1cb376..a232884 100644 --- a/apps/wiki/src/pages/api/tags.ts +++ b/apps/wiki/src/pages/api/tags.ts @@ -1,3 +1,4 @@ +import {serveApiDocumentation} from '@/lib/api-documentation'; import type {NextApiRequest,NextApiResponse} from 'next'; import {randomUUID} from 'node:crypto'; import {z} from 'zod'; @@ -5,6 +6,7 @@ import {db} from '@/lib/db'; import {tags} from '@/lib/library'; import {sameOrigin,apiError} from '@/lib/api'; export default async function handler(req:NextApiRequest,res:NextApiResponse){ + if(serveApiDocumentation(req,res,'/api/tags'))return; try{ if(req.method==='GET')return res.json(await tags()); if(req.method!=='POST')return res.status(405).end(); diff --git a/apps/wiki/src/pages/api/upload.ts b/apps/wiki/src/pages/api/upload.ts index fe2715f..02bc364 100644 --- a/apps/wiki/src/pages/api/upload.ts +++ b/apps/wiki/src/pages/api/upload.ts @@ -1,3 +1,4 @@ +import {serveApiDocumentation} from '@/lib/api-documentation'; import type { NextApiRequest,NextApiResponse } from "next"; import formidable,{type File} from "formidable"; import { randomUUID,createHash } from "node:crypto"; @@ -12,6 +13,7 @@ import { sameOrigin,apiError } from "@/lib/api"; export const config={api:{bodyParser:false}}; export type UploadPolicy = { validateImage?: (path:string) => Promise; defaultTitle?:string }; export async function upload(req:NextApiRequest,res:NextApiResponse,policy:UploadPolicy={}){ + if(serveApiDocumentation(req,res,'/api/upload'))return; if(req.method!=='POST')return res.status(405).end(); const temporary:string[]=[];const uploaded:string[]=[]; try{ diff --git a/apps/wiki/tests/api-documentation.test.ts b/apps/wiki/tests/api-documentation.test.ts new file mode 100644 index 0000000..9acb48e --- /dev/null +++ b/apps/wiki/tests/api-documentation.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import type {NextApiRequest,NextApiResponse} from 'next'; +import {apiDocuments,serveApiDocumentation} from '../src/lib/api-documentation'; +import index from '../src/pages/api/index'; + +function response(){ + const state={status:0,headers:{} as Record,body:undefined as unknown}; + const res={setHeader(name:string,value:string){state.headers[name]=value;return res;},status(code:number){state.status=code;return res;},send(body:unknown){state.body=body;return res;},json(body:unknown){state.body=body;return res;},end(){return res;}}; + return {state,res:res as unknown as NextApiResponse}; +} +const request=(method:string,agent_mode:unknown)=>({method,query:{agent_mode}} as unknown as NextApiRequest); + +test('every business handler serves documentation before validating parameters or accessing dependencies',async()=>{ + const routes:Record={ + '/api/explore-feed':'explore-feed','/api/feed':'feed','/api/notes/{id}':'notes/[id]', + '/api/posts':'posts/index','/api/tags':'tags','/api/upload':'upload', + '/api/media/{id}':'media/[id]','/api/preview':'preview', + '/api/skills/street-people':'skills/street-people','/api/health':'health','/api/ready':'ready', + }; + for(const [path,route] of Object.entries(routes)){ + const {default:handler}=await import('../src/pages/api/'+route+'.ts'); + for(const method of ['GET','HEAD']){ + const {state,res}=response(); + await handler(request(method,'tRuE'),res); + assert.equal(state.status,200,path); + assert.equal(state.headers['Content-Type'],'text/plain; charset=utf-8',path); + if(method==='HEAD')assert.equal(state.body,undefined);else { + assert.ok(String(state.body).includes(path),path); + assert.ok(String(state.body).includes('参数'),path); + } + } + } + assert.deepEqual(Object.keys(routes).sort(),Object.keys(apiDocuments).sort()); +}); + +test('normal calls and mutations do not enter documentation mode',()=>{ + for(const [method,value] of [['GET',undefined],['GET','false'],['GET',['True','false']],['POST','True'],['PUT','True'],['DELETE','True']]){ + const {state,res}=response(); + assert.equal(serveApiDocumentation(request(method as string,value),res,'/api/posts'),false); + assert.equal(state.status,0); + } +}); + +test('index exposes every business endpoint and its text URL',()=>{ + const {state,res}=response();index(request('GET','True'),res); + for(const path of [...Object.keys(apiDocuments),'/studio/api/export/{slug}','/studio/api/health'])assert.ok(String(state.body).includes(path+'?agent_mode=True')); +}); + +test('health keeps its regular JSON response',async()=>{ + const {default:handler}=await import('../src/pages/api/health'); + const {state,res}=response();handler(request('GET',undefined),res); + assert.equal(state.status,200);assert.equal((state.body as {status:string}).status,'ok'); +}); + diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..1e80d53 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,16 @@ +# 业务 API 说明 + +业务 API 目录:`/api`(JSON)或 `/api?agent_mode=True`(纯文本)。 + +每个业务接口都使用原路径加 `agent_mode=True` 返回 `text/plain; charset=utf-8` 的接口说明,包含用途、方法、参数、响应、调用示例和错误。查询值不区分大小写;已有查询参数时使用 `&agent_mode=True`。使用 GET 或 HEAD 读取说明,HEAD 不含正文;说明不查询数据库或对象存储,不要求动态 ID 实际存在。 + +例如: + +- `/api/upload?agent_mode=True` +- `/api/notes/{id}?agent_mode=True` +- `/api/preview?source=%2Fexample.png&agent_mode=True` +- `/studio/api/export/{slug}?agent_mode=True` + +业务调用时移除该参数。POST、PUT 等写入方法保持原有行为,不会因该参数变成文档请求。页面正文和 Markdown 兼容接口继续使用原有规则。 + +Wiki 文档注册表位于 `apps/wiki/src/lib/api-documentation.ts`,由各业务路由在 handler 开头调用;目录从同一注册表生成。Studio 文档位于 `apps/studio/src/lib/api-documentation.ts`,随创作站独立部署。