feat: 从 FastAPI 导出 openapi.json 并渲染为 API 文档
通过 vitepress-openapi 插件,在 VitePress 文档中实现基于 openapi.json 的 API 文档。 这样实现的 API 文档可以从最新版本的代码库中提取路由信息,继而实现自动集成。 --- 同时,通过 Kimi 和 Deepseek 实现并审查了一个对 Google 风格 docstring 的解析函数。 该函数可以从 Google 风格的 docstring 中提取参数文档并按 openapi 规范重新整理它们。 --- 增加了 nyahome openapi 命令用来导出 openapi.json。 增加了 task openapi-docs 命令用来准备未来的持续集成。
This commit is contained in:
+41
-16
@@ -1,4 +1,12 @@
|
|||||||
import {defineConfig} from 'vitepress'
|
import { defineConfig } from 'vitepress'
|
||||||
|
import { useSidebar } from 'vitepress-openapi'
|
||||||
|
import spec from './theme/openapi.json' with { type: 'json' }
|
||||||
|
|
||||||
|
|
||||||
|
const sidebar = useSidebar({
|
||||||
|
spec,
|
||||||
|
linkPrefix: '/dev/api-docs/'
|
||||||
|
})
|
||||||
|
|
||||||
// https://vitepress.dev/reference/site-config
|
// https://vitepress.dev/reference/site-config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -7,29 +15,46 @@ export default defineConfig({
|
|||||||
themeConfig: {
|
themeConfig: {
|
||||||
// https://vitepress.dev/reference/default-theme-config
|
// https://vitepress.dev/reference/default-theme-config
|
||||||
nav: [
|
nav: [
|
||||||
{text: '主页', link: '/'},
|
{ text: '主页', link: '/' },
|
||||||
{text: '使用', link: '/use/start'}
|
{ text: '使用', link: '/use/start' },
|
||||||
|
{ text: '开发', link: '/dev/start' },
|
||||||
],
|
],
|
||||||
|
|
||||||
sidebar: [
|
sidebar: {
|
||||||
{
|
'use': [{
|
||||||
text: '使用',
|
text: '使用',
|
||||||
items: [
|
items: [
|
||||||
{text: '开始', link: '/use/start'},
|
{ text: '开始', link: '/use/start' },
|
||||||
{text: '默认行为', link: '/use/default-action'},
|
{ text: '默认行为', link: '/use/default_action' },
|
||||||
{text: '用户系统', link: '/use/user-system'},
|
{ text: '用户系统', link: '/use/user_system' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
text: '开发',
|
|
||||||
items: [
|
'dev': [
|
||||||
{text: '后端响应', link: '/dev/backend-response'},
|
{
|
||||||
]
|
text: '开发',
|
||||||
},
|
items: [
|
||||||
],
|
{ text: '开始', link: '/dev/start' },
|
||||||
|
{ text: '后端响应', link: '/dev/backend_response' },
|
||||||
|
{ text: '邮件模板', link: '/dev/email_templates' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'API 文档',
|
||||||
|
items: [
|
||||||
|
{ text: 'API', link: '/dev/api-docs/api' },
|
||||||
|
...sidebar.generateSidebarGroups({}),
|
||||||
|
]
|
||||||
|
},]
|
||||||
|
},
|
||||||
|
|
||||||
socialLinks: [
|
socialLinks: [
|
||||||
{icon: 'github', link: 'https://github.com/vuejs/vitepress'}
|
{ icon: 'github', link: 'https://github.com/vuejs/vitepress' }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
sitemap: {
|
||||||
|
hostname: 'https://docs.nyahome.cn'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,51 @@
|
|||||||
:root {
|
:root {
|
||||||
--vp-c-brand-1: #64ffc4;
|
--vp-c-brand-1: #64ffc4;
|
||||||
--vp-c-brand-2: #9354ff;
|
--vp-c-brand-2: #9354ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 简约滚动条美化 ===== */
|
||||||
|
|
||||||
|
/* 适用于 Webkit 内核(Chrome/Edge/Safari) */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
/* 垂直滚动条宽度 */
|
||||||
|
height: 6px;
|
||||||
|
/* 水平滚动条高度 */
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
/* 轨道透明,极简 */
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
/* 滑块半透明灰 */
|
||||||
|
border-radius: 3px;
|
||||||
|
/* 小圆角 */
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
/* 悬停稍微深一点 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox 兼容 */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* vitepress-openapi */
|
||||||
|
div.vitepress-openapi {
|
||||||
|
border: 1px solid #64ffc4;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 48px auto 0;
|
||||||
|
padding: 6px 20px;
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.vitepress-openapi p {
|
||||||
|
line-height: 8px;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,26 @@
|
|||||||
import DefaultTheme from 'vitepress/theme'
|
import DefaultTheme from 'vitepress/theme'
|
||||||
|
import { theme, useOpenapi } from 'vitepress-openapi/client'
|
||||||
|
import 'vitepress-openapi/dist/style.css'
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import './custom.css'
|
import './custom.css'
|
||||||
|
import { Theme } from 'vitepress'
|
||||||
|
|
||||||
export default DefaultTheme
|
import spec from './openapi.json' with { type: 'json' }
|
||||||
|
|
||||||
|
export default {
|
||||||
|
extends: DefaultTheme,
|
||||||
|
async enhanceApp({ app }) {
|
||||||
|
useOpenapi({
|
||||||
|
spec,
|
||||||
|
config: {
|
||||||
|
codeSamples: {
|
||||||
|
defaultLang: 'python',
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
allowCustomServer: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
theme.enhanceApp({ app })
|
||||||
|
}
|
||||||
|
} satisfies Theme
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
aside: false
|
||||||
|
outline: false
|
||||||
|
title: NyaHome API Docs
|
||||||
|
---
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useTheme, locals } from 'vitepress-openapi/client'
|
||||||
|
import { useRoute } from 'vitepress'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
useTheme({
|
||||||
|
i18n: {
|
||||||
|
locale: 'zh',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const operationId = route.data.params.operationId
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<OAOperation :operationId="operationId">
|
||||||
|
<template #branding>
|
||||||
|
<div class="vitepress-openapi">
|
||||||
|
<p>API 文档是基于最新代码自动生成的</p>
|
||||||
|
<p>由 VitePress OpenAPI 提供文档支持</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</OAOperation>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { usePaths } from 'vitepress-openapi'
|
||||||
|
import spec from '../../.vitepress/theme/openapi.json' with {type: 'json'}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
paths() {
|
||||||
|
return usePaths({ spec })
|
||||||
|
.getPathsByVerbs()
|
||||||
|
.map(({ operationId, summary }) => {
|
||||||
|
return {
|
||||||
|
params: {
|
||||||
|
operationId,
|
||||||
|
pageTitle: `${summary} - NyaHome API`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
+4
-2
@@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
|
"type": "module",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"mjml": "^5.2.2",
|
"mjml": "^5.2.2",
|
||||||
"vitepress": "2.0.0-alpha.17"
|
"vitepress": "2.0.0-alpha.17",
|
||||||
|
"vitepress-openapi": "^0.2.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vitepress dev docs",
|
"dev": "vitepress dev docs --port 6173",
|
||||||
"build": "vitepress build docs",
|
"build": "vitepress build docs",
|
||||||
"preview": "vitepress preview docs"
|
"preview": "vitepress preview docs"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+178
@@ -14,6 +14,9 @@ importers:
|
|||||||
vitepress:
|
vitepress:
|
||||||
specifier: 2.0.0-alpha.17
|
specifier: 2.0.0-alpha.17
|
||||||
version: 2.0.0-alpha.17(postcss@8.5.15)
|
version: 2.0.0-alpha.17(postcss@8.5.15)
|
||||||
|
vitepress-openapi:
|
||||||
|
specifier: ^0.2.0
|
||||||
|
version: 0.2.0(vitepress@2.0.0-alpha.17(postcss@8.5.15))(vue@3.5.34)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -210,12 +213,30 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@floating-ui/core@1.7.5':
|
||||||
|
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
|
||||||
|
|
||||||
|
'@floating-ui/dom@1.7.6':
|
||||||
|
resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
|
||||||
|
|
||||||
|
'@floating-ui/utils@0.2.11':
|
||||||
|
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
|
||||||
|
|
||||||
|
'@floating-ui/vue@1.1.11':
|
||||||
|
resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==}
|
||||||
|
|
||||||
'@iconify-json/simple-icons@1.2.83':
|
'@iconify-json/simple-icons@1.2.83':
|
||||||
resolution: {integrity: sha512-6Pp9V++XisT9RKH7FB4RLPqUDzcmLtSma0ovOEIoEWGrXtHwBFsH7oN1z8vvCVCb95fb87QgR46/zRLyN9Y3kg==}
|
resolution: {integrity: sha512-6Pp9V++XisT9RKH7FB4RLPqUDzcmLtSma0ovOEIoEWGrXtHwBFsH7oN1z8vvCVCb95fb87QgR46/zRLyN9Y3kg==}
|
||||||
|
|
||||||
'@iconify/types@2.0.0':
|
'@iconify/types@2.0.0':
|
||||||
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
||||||
|
|
||||||
|
'@internationalized/date@3.12.1':
|
||||||
|
resolution: {integrity: sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==}
|
||||||
|
|
||||||
|
'@internationalized/number@3.6.6':
|
||||||
|
resolution: {integrity: sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==}
|
||||||
|
|
||||||
'@isaacs/cliui@8.0.2':
|
'@isaacs/cliui@8.0.2':
|
||||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -395,6 +416,17 @@ packages:
|
|||||||
'@shikijs/vscode-textmate@10.0.2':
|
'@shikijs/vscode-textmate@10.0.2':
|
||||||
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
||||||
|
|
||||||
|
'@swc/helpers@0.5.21':
|
||||||
|
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.15.0':
|
||||||
|
resolution: {integrity: sha512-0AwPGx0I8QxPYjAxShT/+z+ZOe9u8mW5rsXvivCTjRfRmz9a43+3mRyi4wwlyoUqOC56q/jatKa0Bh9M99BEHQ==}
|
||||||
|
|
||||||
|
'@tanstack/vue-virtual@3.13.25':
|
||||||
|
resolution: {integrity: sha512-/ez+t68a5O4CgVysvk7Bav0XbSYSYufOVHZveXF+DYO9hvtg2UheYzR0YkniCeUtXmMjDne1dDqwBMkOmEUOow==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^2.7.0 || ^3.0.0
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
@@ -556,6 +588,10 @@ packages:
|
|||||||
argparse@2.0.1:
|
argparse@2.0.1:
|
||||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||||
|
|
||||||
|
aria-hidden@1.2.6:
|
||||||
|
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
@@ -616,10 +652,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||||
engines: {node: '>= 8.10.0'}
|
engines: {node: '>= 8.10.0'}
|
||||||
|
|
||||||
|
class-variance-authority@0.7.1:
|
||||||
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
cliui@8.0.1:
|
cliui@8.0.1:
|
||||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
clsx@2.1.1:
|
||||||
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@@ -719,6 +762,9 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
defu@6.1.7:
|
||||||
|
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
|
||||||
|
|
||||||
dequal@2.0.3:
|
dequal@2.0.3:
|
||||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -986,12 +1032,21 @@ packages:
|
|||||||
lru-cache@10.4.3:
|
lru-cache@10.4.3:
|
||||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||||
|
|
||||||
|
lucide-vue-next@1.0.0:
|
||||||
|
resolution: {integrity: sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==}
|
||||||
|
deprecated: Package deprecated. Please use @lucide/vue instead.
|
||||||
|
peerDependencies:
|
||||||
|
vue: '>=3.0.1'
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
mark.js@8.11.1:
|
mark.js@8.11.1:
|
||||||
resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==}
|
resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==}
|
||||||
|
|
||||||
|
markdown-it-link-attributes@4.0.1:
|
||||||
|
resolution: {integrity: sha512-pg5OK0jPLg62H4k7M9mRJLT61gUp9nvG0XveKYHMOOluASo9OEF13WlXrpAp2aj35LbedAy3QOCgQCw0tkLKAQ==}
|
||||||
|
|
||||||
mdast-util-to-hast@13.2.1:
|
mdast-util-to-hast@13.2.1:
|
||||||
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
|
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
|
||||||
|
|
||||||
@@ -1151,6 +1206,9 @@ packages:
|
|||||||
nth-check@2.1.1:
|
nth-check@2.1.1:
|
||||||
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
||||||
|
|
||||||
|
ohash@2.0.11:
|
||||||
|
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
||||||
|
|
||||||
oniguruma-parser@0.12.2:
|
oniguruma-parser@0.12.2:
|
||||||
resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
|
resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
|
||||||
|
|
||||||
@@ -1403,6 +1461,11 @@ packages:
|
|||||||
regex@6.1.0:
|
regex@6.1.0:
|
||||||
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
|
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
|
||||||
|
|
||||||
|
reka-ui@2.9.8:
|
||||||
|
resolution: {integrity: sha512-7dxaBJ6nQ0zOQZXPV45219tTEgZPstmihBLS9ABPhSiPiJ8SiF0sacfZHFaBptS0v9N4tzsevq+8MNBpE4p5JQ==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: '>= 3.4.0'
|
||||||
|
|
||||||
require-directory@2.1.1:
|
require-directory@2.1.1:
|
||||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1497,6 +1560,9 @@ packages:
|
|||||||
trim-lines@3.0.1:
|
trim-lines@3.0.1:
|
||||||
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
|
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
undici@6.25.0:
|
undici@6.25.0:
|
||||||
resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==}
|
resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==}
|
||||||
engines: {node: '>=18.17'}
|
engines: {node: '>=18.17'}
|
||||||
@@ -1575,6 +1641,13 @@ packages:
|
|||||||
yaml:
|
yaml:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
vitepress-openapi@0.2.0:
|
||||||
|
resolution: {integrity: sha512-suCYD59HG0P+XKauEm2GayqeXiVqBnDfaltmwcqNS3zOUDgI/nDCN75z4zZqLGHLLw/YhryNko8LOykTaUGmtQ==}
|
||||||
|
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
|
||||||
|
peerDependencies:
|
||||||
|
vitepress: '>=1.0.0'
|
||||||
|
vue: ^3.0.0
|
||||||
|
|
||||||
vitepress@2.0.0-alpha.17:
|
vitepress@2.0.0-alpha.17:
|
||||||
resolution: {integrity: sha512-Z3VPUpwk/bHYqt1uMVOOK1/4xFiWQov1GNc2FvMdz6kvje4JRXEOngVI9C+bi5jeedMSHiA4dwKkff1NCvbZ9Q==}
|
resolution: {integrity: sha512-Z3VPUpwk/bHYqt1uMVOOK1/4xFiWQov1GNc2FvMdz6kvje4JRXEOngVI9C+bi5jeedMSHiA4dwKkff1NCvbZ9Q==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -1590,6 +1663,17 @@ packages:
|
|||||||
postcss:
|
postcss:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
vue-demi@0.14.10:
|
||||||
|
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@vue/composition-api': ^1.0.0-rc.1
|
||||||
|
vue: ^3.0.0-0 || ^2.6.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@vue/composition-api':
|
||||||
|
optional: true
|
||||||
|
|
||||||
vue@3.5.34:
|
vue@3.5.34:
|
||||||
resolution: {integrity: sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==}
|
resolution: {integrity: sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1748,12 +1832,40 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.27.7':
|
'@esbuild/win32-x64@0.27.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@floating-ui/core@1.7.5':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/utils': 0.2.11
|
||||||
|
|
||||||
|
'@floating-ui/dom@1.7.6':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/core': 1.7.5
|
||||||
|
'@floating-ui/utils': 0.2.11
|
||||||
|
|
||||||
|
'@floating-ui/utils@0.2.11': {}
|
||||||
|
|
||||||
|
'@floating-ui/vue@1.1.11(vue@3.5.34)':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/dom': 1.7.6
|
||||||
|
'@floating-ui/utils': 0.2.11
|
||||||
|
vue-demi: 0.14.10(vue@3.5.34)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@vue/composition-api'
|
||||||
|
- vue
|
||||||
|
|
||||||
'@iconify-json/simple-icons@1.2.83':
|
'@iconify-json/simple-icons@1.2.83':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
'@iconify/types@2.0.0': {}
|
'@iconify/types@2.0.0': {}
|
||||||
|
|
||||||
|
'@internationalized/date@3.12.1':
|
||||||
|
dependencies:
|
||||||
|
'@swc/helpers': 0.5.21
|
||||||
|
|
||||||
|
'@internationalized/number@3.6.6':
|
||||||
|
dependencies:
|
||||||
|
'@swc/helpers': 0.5.21
|
||||||
|
|
||||||
'@isaacs/cliui@8.0.2':
|
'@isaacs/cliui@8.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 5.1.2
|
string-width: 5.1.2
|
||||||
@@ -1885,6 +1997,17 @@ snapshots:
|
|||||||
|
|
||||||
'@shikijs/vscode-textmate@10.0.2': {}
|
'@shikijs/vscode-textmate@10.0.2': {}
|
||||||
|
|
||||||
|
'@swc/helpers@0.5.21':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.15.0': {}
|
||||||
|
|
||||||
|
'@tanstack/vue-virtual@3.13.25(vue@3.5.34)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/virtual-core': 3.15.0
|
||||||
|
vue: 3.5.34
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/hast@3.0.4':
|
'@types/hast@3.0.4':
|
||||||
@@ -2027,6 +2150,10 @@ snapshots:
|
|||||||
|
|
||||||
argparse@2.0.1: {}
|
argparse@2.0.1: {}
|
||||||
|
|
||||||
|
aria-hidden@1.2.6:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
baseline-browser-mapping@2.10.31: {}
|
baseline-browser-mapping@2.10.31: {}
|
||||||
@@ -2105,12 +2232,18 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
class-variance-authority@0.7.1:
|
||||||
|
dependencies:
|
||||||
|
clsx: 2.1.1
|
||||||
|
|
||||||
cliui@8.0.1:
|
cliui@8.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 4.2.3
|
string-width: 4.2.3
|
||||||
strip-ansi: 6.0.1
|
strip-ansi: 6.0.1
|
||||||
wrap-ansi: 7.0.0
|
wrap-ansi: 7.0.0
|
||||||
|
|
||||||
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
@@ -2229,6 +2362,8 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
defu@6.1.7: {}
|
||||||
|
|
||||||
dequal@2.0.3: {}
|
dequal@2.0.3: {}
|
||||||
|
|
||||||
detect-node@2.1.0: {}
|
detect-node@2.1.0: {}
|
||||||
@@ -2502,12 +2637,18 @@ snapshots:
|
|||||||
|
|
||||||
lru-cache@10.4.3: {}
|
lru-cache@10.4.3: {}
|
||||||
|
|
||||||
|
lucide-vue-next@1.0.0(vue@3.5.34):
|
||||||
|
dependencies:
|
||||||
|
vue: 3.5.34
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
mark.js@8.11.1: {}
|
mark.js@8.11.1: {}
|
||||||
|
|
||||||
|
markdown-it-link-attributes@4.0.1: {}
|
||||||
|
|
||||||
mdast-util-to-hast@13.2.1:
|
mdast-util-to-hast@13.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
@@ -3026,6 +3167,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
boolbase: 1.0.0
|
boolbase: 1.0.0
|
||||||
|
|
||||||
|
ohash@2.0.11: {}
|
||||||
|
|
||||||
oniguruma-parser@0.12.2: {}
|
oniguruma-parser@0.12.2: {}
|
||||||
|
|
||||||
oniguruma-to-es@4.3.6:
|
oniguruma-to-es@4.3.6:
|
||||||
@@ -3270,6 +3413,22 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
regex-utilities: 2.3.0
|
regex-utilities: 2.3.0
|
||||||
|
|
||||||
|
reka-ui@2.9.8(vue@3.5.34):
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/dom': 1.7.6
|
||||||
|
'@floating-ui/vue': 1.1.11(vue@3.5.34)
|
||||||
|
'@internationalized/date': 3.12.1
|
||||||
|
'@internationalized/number': 3.6.6
|
||||||
|
'@tanstack/vue-virtual': 3.13.25(vue@3.5.34)
|
||||||
|
'@vueuse/core': 14.3.0(vue@3.5.34)
|
||||||
|
'@vueuse/shared': 14.3.0(vue@3.5.34)
|
||||||
|
aria-hidden: 1.2.6
|
||||||
|
defu: 6.1.7
|
||||||
|
ohash: 2.0.11
|
||||||
|
vue: 3.5.34
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@vue/composition-api'
|
||||||
|
|
||||||
require-directory@2.1.1: {}
|
require-directory@2.1.1: {}
|
||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
@@ -3390,6 +3549,8 @@ snapshots:
|
|||||||
|
|
||||||
trim-lines@3.0.1: {}
|
trim-lines@3.0.1: {}
|
||||||
|
|
||||||
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
undici@6.25.0: {}
|
undici@6.25.0: {}
|
||||||
|
|
||||||
unist-util-is@6.0.1:
|
unist-util-is@6.0.1:
|
||||||
@@ -3446,6 +3607,19 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
vitepress-openapi@0.2.0(vitepress@2.0.0-alpha.17(postcss@8.5.15))(vue@3.5.34):
|
||||||
|
dependencies:
|
||||||
|
'@vueuse/core': 14.3.0(vue@3.5.34)
|
||||||
|
class-variance-authority: 0.7.1
|
||||||
|
clsx: 2.1.1
|
||||||
|
lucide-vue-next: 1.0.0(vue@3.5.34)
|
||||||
|
markdown-it-link-attributes: 4.0.1
|
||||||
|
reka-ui: 2.9.8(vue@3.5.34)
|
||||||
|
vitepress: 2.0.0-alpha.17(postcss@8.5.15)
|
||||||
|
vue: 3.5.34
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@vue/composition-api'
|
||||||
|
|
||||||
vitepress@2.0.0-alpha.17(postcss@8.5.15):
|
vitepress@2.0.0-alpha.17(postcss@8.5.15):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@docsearch/css': 4.6.3
|
'@docsearch/css': 4.6.3
|
||||||
@@ -3494,6 +3668,10 @@ snapshots:
|
|||||||
- universal-cookie
|
- universal-cookie
|
||||||
- yaml
|
- yaml
|
||||||
|
|
||||||
|
vue-demi@0.14.10(vue@3.5.34):
|
||||||
|
dependencies:
|
||||||
|
vue: 3.5.34
|
||||||
|
|
||||||
vue@3.5.34:
|
vue@3.5.34:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/compiler-dom': 3.5.34
|
'@vue/compiler-dom': 3.5.34
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ dependencies = [
|
|||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
"docstring-parser>=0.18.0",
|
||||||
"mypy>=2.1.0",
|
"mypy>=2.1.0",
|
||||||
"ruff>=0.15.14",
|
"ruff>=0.15.14",
|
||||||
"taskipy>=1.14.1",
|
"taskipy>=1.14.1",
|
||||||
@@ -110,3 +111,5 @@ migrate = "alembic upgrade head"
|
|||||||
rollback = "alembic downgrade -1"
|
rollback = "alembic downgrade -1"
|
||||||
# 查看数据库版本
|
# 查看数据库版本
|
||||||
current = "alembic current"
|
current = "alembic current"
|
||||||
|
# 为 docs 文档生成最新 openapi.json
|
||||||
|
openapi-docs = "nyahome openapi docs/.vitepress/theme/openapi.json"
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
"""
|
||||||
|
FastAPI Docstring-to-OpenAPI Enricher
|
||||||
|
=====================================
|
||||||
|
|
||||||
|
自动解析 Google Style docstring,将 Args / Returns / Raises 注入到 OpenAPI Schema 的对应位置,
|
||||||
|
让 vitepress-openapi 等工具能够正确渲染参数表格和返回值说明。
|
||||||
|
|
||||||
|
功能特性
|
||||||
|
--------
|
||||||
|
1. 参数描述注入:路径参数、查询参数、Header、Cookie、请求体字段
|
||||||
|
2. 返回值描述注入:自动写入 2xx 响应的 description 及 schema.description
|
||||||
|
3. 异常描述注入:以 Markdown 格式附加到 operation description
|
||||||
|
4. 嵌套 Schema 递归处理:支持 Pydantic v1/v2 的嵌套模型、List、Optional 等
|
||||||
|
5. \f 截断兼容:与 FastAPI 原生行为保持一致
|
||||||
|
6. 全局 Schema 补全:为 components/schemas 中缺少描述的字段补充 docstring 说明
|
||||||
|
7. 零侵入:只需替换 ``app.openapi``,业务代码无需任何修改
|
||||||
|
|
||||||
|
依赖安装
|
||||||
|
--------
|
||||||
|
pip install docstring-parser
|
||||||
|
|
||||||
|
使用方式
|
||||||
|
--------
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi_docstring_openapi import enrich_openapi_from_docstrings
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
enrich_openapi_from_docstrings(app)
|
||||||
|
|
||||||
|
# 你的路由注册...
|
||||||
|
|
||||||
|
完整示例见文件底部 ``if __name__ == "__main__":`` 块。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, Optional, Set
|
||||||
|
|
||||||
|
from docstring_parser import DocstringStyle, ParseError, parse
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.openapi.utils import get_openapi
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_docstring(
|
||||||
|
func: Any, style: DocstringStyle = DocstringStyle.AUTO
|
||||||
|
) -> Optional[Any]:
|
||||||
|
"""解析函数的 Google Style docstring,失败时返回 None。"""
|
||||||
|
doc = inspect.getdoc(func)
|
||||||
|
if not doc:
|
||||||
|
return None
|
||||||
|
if "\f" in doc:
|
||||||
|
doc = doc.split("\f")[0].strip()
|
||||||
|
try:
|
||||||
|
return parse(doc, style=style)
|
||||||
|
except ParseError:
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Unexpected error parsing docstring for %s",
|
||||||
|
getattr(func, "__name__", func),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_param_lookup(parsed_doc: Any) -> Dict[str, str]:
|
||||||
|
"""从解析后的 docstring 构建 {参数名: 描述} 映射。"""
|
||||||
|
lookup: Dict[str, str] = {}
|
||||||
|
for param in getattr(parsed_doc, "params", []) or []:
|
||||||
|
name = getattr(param, "arg_name", None)
|
||||||
|
desc = getattr(param, "description", None) or ""
|
||||||
|
if name:
|
||||||
|
# 合并多行描述并清理缩进
|
||||||
|
lookup[name] = _dedent_description(desc)
|
||||||
|
return lookup
|
||||||
|
|
||||||
|
|
||||||
|
def _build_returns_description(parsed_doc: Any) -> Optional[str]:
|
||||||
|
"""从解析后的 docstring 构建返回值描述。"""
|
||||||
|
ret = getattr(parsed_doc, "returns", None)
|
||||||
|
if not ret:
|
||||||
|
return None
|
||||||
|
type_name = getattr(ret, "type_name", None) or ""
|
||||||
|
desc = getattr(ret, "description", None) or ""
|
||||||
|
if type_name and desc:
|
||||||
|
return f"**{type_name}**: {desc}"
|
||||||
|
return desc or None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_raises_description(parsed_doc: Any) -> Optional[str]:
|
||||||
|
"""从解析后的 docstring 构建异常描述(Markdown 列表)。"""
|
||||||
|
raises = getattr(parsed_doc, "raises", None)
|
||||||
|
if not raises:
|
||||||
|
return None
|
||||||
|
parts: list[str] = []
|
||||||
|
for exc in raises:
|
||||||
|
type_name = getattr(exc, "type_name", None) or ""
|
||||||
|
desc = getattr(exc, "description", None) or ""
|
||||||
|
if type_name and desc:
|
||||||
|
parts.append(f"- **{type_name}**: {desc}")
|
||||||
|
elif type_name:
|
||||||
|
parts.append(f"- **{type_name}**")
|
||||||
|
elif desc:
|
||||||
|
parts.append(f"- {desc}")
|
||||||
|
return "\n".join(parts) if parts else None
|
||||||
|
|
||||||
|
|
||||||
|
def _dedent_description(text: str) -> str:
|
||||||
|
"""清理 docstring 描述中的多余缩进,保留段落结构。"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
lines = text.splitlines()
|
||||||
|
# 找到最小公共缩进(排除空行)
|
||||||
|
min_indent = min(
|
||||||
|
(len(line) - len(line.lstrip()) for line in lines if line.strip()),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
cleaned = [line[min_indent:] if line.strip() else "" for line in lines]
|
||||||
|
return "\n".join(cleaned).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_clean_operation_description(parsed_doc: Any) -> str:
|
||||||
|
"""
|
||||||
|
生成干净的 operation description。
|
||||||
|
|
||||||
|
策略:保留 summary + long_description,去掉 Args/Returns/Raises 等机器块。
|
||||||
|
"""
|
||||||
|
parts: list[str] = []
|
||||||
|
short = getattr(parsed_doc, "short_description", None)
|
||||||
|
long_ = getattr(parsed_doc, "long_description", None)
|
||||||
|
|
||||||
|
if short:
|
||||||
|
parts.append(short)
|
||||||
|
if long_:
|
||||||
|
parts.append(long_)
|
||||||
|
|
||||||
|
return "\n\n".join(parts).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_schema_properties(
|
||||||
|
schema: Dict[str, Any],
|
||||||
|
param_lookup: Dict[str, str],
|
||||||
|
visited_refs: Optional[Set[str]] = None,
|
||||||
|
openapi_schema: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
递归注入 schema properties 的描述。
|
||||||
|
|
||||||
|
支持对象、数组、allOf/anyOf/oneOf、$ref 引用(全局 components/schemas 补全)。
|
||||||
|
"""
|
||||||
|
if visited_refs is None:
|
||||||
|
visited_refs = set()
|
||||||
|
if not isinstance(schema, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
# 处理 $ref:在 components/schemas 中查找并补全
|
||||||
|
ref = schema.get("$ref")
|
||||||
|
if ref and openapi_schema:
|
||||||
|
if ref not in visited_refs:
|
||||||
|
visited_refs.add(ref)
|
||||||
|
# 提取 schema 名,例如 "#/components/schemas/User" -> "User"
|
||||||
|
schema_name = ref.split("/")[-1]
|
||||||
|
components = openapi_schema.get("components", {}).get("schemas", {})
|
||||||
|
target = components.get(schema_name)
|
||||||
|
if target:
|
||||||
|
_inject_schema_properties(target, param_lookup, visited_refs, openapi_schema)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 处理 properties(对象字段)
|
||||||
|
properties = schema.get("properties")
|
||||||
|
if isinstance(properties, dict):
|
||||||
|
for prop_name, prop_schema in properties.items():
|
||||||
|
if prop_name in param_lookup:
|
||||||
|
# 只有当 docstring 描述非空时才写入
|
||||||
|
if param_lookup[prop_name]:
|
||||||
|
prop_schema["description"] = param_lookup[prop_name]
|
||||||
|
# 递归处理子 schema
|
||||||
|
_inject_schema_properties(prop_schema, param_lookup, visited_refs, openapi_schema)
|
||||||
|
|
||||||
|
# 处理 items(数组类型)
|
||||||
|
items = schema.get("items")
|
||||||
|
if items:
|
||||||
|
_inject_schema_properties(items, param_lookup, visited_refs, openapi_schema)
|
||||||
|
|
||||||
|
# 处理 allOf / anyOf / oneOf
|
||||||
|
for key in ("allOf", "anyOf", "oneOf"):
|
||||||
|
for sub in schema.get(key, []):
|
||||||
|
_inject_schema_properties(sub, param_lookup, visited_refs, openapi_schema)
|
||||||
|
|
||||||
|
# 处理 additionalProperties
|
||||||
|
additional = schema.get("additionalProperties")
|
||||||
|
if isinstance(additional, dict):
|
||||||
|
_inject_schema_properties(additional, param_lookup, visited_refs, openapi_schema)
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_openapi_from_docstrings(
|
||||||
|
app: FastAPI,
|
||||||
|
*,
|
||||||
|
prefer_docstring_over_field: bool = True,
|
||||||
|
append_raises_to_description: bool = True,
|
||||||
|
docstring_style: DocstringStyle = DocstringStyle.AUTO,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
为 FastAPI 应用启用 docstring 驱动的 OpenAPI 增强。
|
||||||
|
|
||||||
|
参数
|
||||||
|
----
|
||||||
|
app : FastAPI
|
||||||
|
目标应用实例。
|
||||||
|
prefer_docstring_over_field : bool, 默认 True
|
||||||
|
当 docstring 中的参数描述与 Pydantic Field(description=...) 冲突时,
|
||||||
|
是否优先使用 docstring 的描述。
|
||||||
|
append_raises_to_description : bool, 默认 True
|
||||||
|
是否将 Raises 段落以 Markdown 格式追加到 operation description。
|
||||||
|
docstring_style : DocstringStyle, 默认 AUTO
|
||||||
|
解析风格。团队统一用 Google 时可显式传入 ``DocstringStyle.GOOGLE``。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 保存原始的 openapi 函数(如果有)
|
||||||
|
original_openapi = app.openapi
|
||||||
|
|
||||||
|
def custom_openapi() -> Dict[str, Any]:
|
||||||
|
# 如果已经有缓存,直接返回
|
||||||
|
if app.openapi_schema:
|
||||||
|
return app.openapi_schema
|
||||||
|
|
||||||
|
# 生成基础 schema
|
||||||
|
openapi_schema = get_openapi(
|
||||||
|
title=app.title,
|
||||||
|
version=app.version,
|
||||||
|
openapi_version=app.openapi_version,
|
||||||
|
description=app.description,
|
||||||
|
routes=app.routes,
|
||||||
|
)
|
||||||
|
|
||||||
|
for route in app.routes:
|
||||||
|
if not hasattr(route, "endpoint") or not route.endpoint:
|
||||||
|
continue
|
||||||
|
|
||||||
|
path = getattr(route, "path", None)
|
||||||
|
methods = [m.lower() for m in route.methods] if hasattr(route, "methods") else []
|
||||||
|
if not path or not methods:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 解析 docstring
|
||||||
|
parsed = _parse_docstring(route.endpoint, style=docstring_style)
|
||||||
|
if not parsed:
|
||||||
|
continue
|
||||||
|
|
||||||
|
param_lookup = _build_param_lookup(parsed)
|
||||||
|
returns_desc = _build_returns_description(parsed)
|
||||||
|
raises_desc = _build_raises_description(parsed)
|
||||||
|
clean_desc = _get_clean_operation_description(parsed)
|
||||||
|
|
||||||
|
for method in methods:
|
||||||
|
if method == "head":
|
||||||
|
# FastAPI 通常不把 HEAD 单独写入 OpenAPI,或者与 GET 共享
|
||||||
|
continue
|
||||||
|
if method not in openapi_schema.get("paths", {}).get(path, {}):
|
||||||
|
continue
|
||||||
|
|
||||||
|
op = openapi_schema["paths"][path][method]
|
||||||
|
|
||||||
|
# ---------- 1. 更新 operation description ----------
|
||||||
|
if clean_desc:
|
||||||
|
op["description"] = clean_desc
|
||||||
|
|
||||||
|
# ---------- 2. 注入参数描述(路径/查询/Header/Cookie)----------
|
||||||
|
for param in op.get("parameters", []):
|
||||||
|
param_name = param.get("name")
|
||||||
|
if param_name and param_name in param_lookup:
|
||||||
|
existing = param.get("description", "")
|
||||||
|
new_desc = param_lookup[param_name]
|
||||||
|
if new_desc and (prefer_docstring_over_field or not existing):
|
||||||
|
param["description"] = new_desc
|
||||||
|
|
||||||
|
# ---------- 3. 注入请求体 schema 字段描述 ----------
|
||||||
|
if "requestBody" in op:
|
||||||
|
content = op["requestBody"].get("content", {})
|
||||||
|
for media_obj in content.values():
|
||||||
|
schema = media_obj.get("schema", {})
|
||||||
|
if schema:
|
||||||
|
_inject_schema_properties(
|
||||||
|
schema, param_lookup, openapi_schema=openapi_schema
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------- 4. 注入返回值描述 ----------
|
||||||
|
if returns_desc:
|
||||||
|
for code, resp in op.get("responses", {}).items():
|
||||||
|
if code.startswith("2"): # 2xx 成功响应
|
||||||
|
# 更新响应级 description
|
||||||
|
resp["description"] = returns_desc
|
||||||
|
# 同时注入到响应 schema 的 description(如果存在)
|
||||||
|
for media_obj in resp.get("content", {}).values():
|
||||||
|
schema = media_obj.get("schema", {})
|
||||||
|
if schema and not schema.get("description"):
|
||||||
|
schema["description"] = returns_desc
|
||||||
|
# 递归注入 schema 内部字段
|
||||||
|
_inject_schema_properties(
|
||||||
|
schema, param_lookup, openapi_schema=openapi_schema
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------- 5. 异常描述追加 ----------
|
||||||
|
if append_raises_to_description and raises_desc:
|
||||||
|
current_desc = op.get("description", "")
|
||||||
|
raises_md = f"## 异常\n\n{raises_desc}"
|
||||||
|
# 避免重复追加
|
||||||
|
if raises_md not in current_desc:
|
||||||
|
op["description"] = f"{current_desc}\n\n{raises_md}".strip()
|
||||||
|
|
||||||
|
app.openapi_schema = openapi_schema
|
||||||
|
return app.openapi_schema
|
||||||
|
|
||||||
|
app.openapi = custom_openapi
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 完整使用示例
|
||||||
|
# =============================================================================
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Path, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
# ---------- 定义 DTO ----------
|
||||||
|
class EditChatDto(BaseModel):
|
||||||
|
old_message: str = Field(..., description="原始消息内容")
|
||||||
|
new_message: str = Field(..., description="修改后的消息内容")
|
||||||
|
change: str = Field(..., description="变更类型")
|
||||||
|
|
||||||
|
class ReturnDto(BaseModel):
|
||||||
|
result: str = Field(..., description="操作结果")
|
||||||
|
content: str = Field(..., description="最新聊天记录内容")
|
||||||
|
|
||||||
|
# ---------- 创建应用 ----------
|
||||||
|
app = FastAPI(title="Chatroom API", version="1.0.0")
|
||||||
|
|
||||||
|
# 启用 docstring 增强(必须在注册路由之前或之后都可以,但要在生成 schema 之前)
|
||||||
|
enrich_openapi_from_docstrings(app)
|
||||||
|
|
||||||
|
# ---------- 注册路由 ----------
|
||||||
|
@app.post("/api/chatroom/{id_}/chat/edit/", response_model=ReturnDto)
|
||||||
|
async def edit_chatroom_chat(
|
||||||
|
id_: Annotated[str, Path(description="聊天室 ID")],
|
||||||
|
body: EditChatDto,
|
||||||
|
force: Annotated[Optional[bool], Query(description="是否强制覆盖")] = None,
|
||||||
|
) -> ReturnDto:
|
||||||
|
"""编辑聊天室消息。
|
||||||
|
|
||||||
|
此端点不负责调用 AI 生成输出,而是用于修改一条已经保存在聊天记录中的消息。
|
||||||
|
前端调用后应使用返回的 content 刷新当前聊天室界面。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
id_: 聊天室唯一标识符,UUID 格式。
|
||||||
|
body: 编辑请求体,包含旧消息、新消息和变更类型。
|
||||||
|
force: 是否跳过冲突检测直接覆盖。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ReturnDto: 操作结果,其中 result 字段表示状态,content 字段为最新聊天记录。
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 表明未找到聊天室。
|
||||||
|
HTTPException: 400 表明聊天记录匹配失败,未更新。
|
||||||
|
"""
|
||||||
|
return ReturnDto(result="ok", content="new content")
|
||||||
|
|
||||||
|
# ---------- 输出生成的 OpenAPI schema ----------
|
||||||
|
schema = app.openapi()
|
||||||
|
print(json.dumps(schema, indent=2, ensure_ascii=False))
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
避免在此文件中引用 router 和 service 模块内的代码。
|
避免在此文件中引用 router 和 service 模块内的代码。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
@@ -54,5 +56,18 @@ def run() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def openapi(
|
||||||
|
path: Annotated[str, typer.Argument(help="导出的 json 格式 openapi.json 应该保存为……")] = "openapi.json",
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
根据代码导出 NyaHome 的 openapi.json 。
|
||||||
|
"""
|
||||||
|
from nyahome.server import save_openapi_json
|
||||||
|
|
||||||
|
save_openapi_json(path)
|
||||||
|
console.print(f"[cyan]已经保存 openapi.json 到 {path} 。[/cyan]")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -53,3 +54,15 @@ app.add_middleware(
|
|||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_openapi_json(save_path: str | Path) -> None:
|
||||||
|
from docstring_parser import DocstringStyle
|
||||||
|
|
||||||
|
from nyahome.cli.openapi_docstring import enrich_openapi_from_docstrings
|
||||||
|
|
||||||
|
# 增强 openapi docstring 参数文档提取
|
||||||
|
enrich_openapi_from_docstrings(app, docstring_style=DocstringStyle.GOOGLE)
|
||||||
|
|
||||||
|
with open(save_path, "w") as f:
|
||||||
|
json.dump(app.openapi(), f, indent=2)
|
||||||
|
|||||||
@@ -355,6 +355,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
|
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "docstring-parser"
|
||||||
|
version = "0.18.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ecdsa"
|
name = "ecdsa"
|
||||||
version = "0.19.2"
|
version = "0.19.2"
|
||||||
@@ -740,6 +749,7 @@ dependencies = [
|
|||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "docstring-parser" },
|
||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
{ name = "taskipy" },
|
{ name = "taskipy" },
|
||||||
@@ -772,6 +782,7 @@ requires-dist = [
|
|||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "docstring-parser", specifier = ">=0.18.0" },
|
||||||
{ name = "mypy", specifier = ">=2.1.0" },
|
{ name = "mypy", specifier = ">=2.1.0" },
|
||||||
{ name = "ruff", specifier = ">=0.15.14" },
|
{ name = "ruff", specifier = ">=0.15.14" },
|
||||||
{ name = "taskipy", specifier = ">=1.14.1" },
|
{ name = "taskipy", specifier = ">=1.14.1" },
|
||||||
|
|||||||
Reference in New Issue
Block a user