Skip to main content

apidoc/
lib.rs

1//! apidoc runtime core: data model, distributed-slice fragment registry and
2//! endpoint aggregation. Attribute macros are re-exported from apidoc-macros.
3//!
4//! Two things to know when consuming this crate:
5//! - Registration happens via `linkme::distributed_slice`, so crates that only
6//!   register documentation (no other use of this crate) must be linked, not
7//!   merely built; call any exported item from the crate root to force it.
8//! - The macros expand to paths like `apidoc::DocFragment`, so consumers must
9//!   depend on `linkme` directly and re-export `distributed_slice` (this crate
10//!   already re-exports it for convenience).
11
12pub use apidoc_macros::*;
13pub use linkme::distributed_slice;
14
15/// M5: markdown / typescript / swagger(OpenAPI3) 三种导出格式。
16pub mod export;
17
18/// M6a: 密码鉴权(authcode token,对齐上游 apidoc-php)。
19pub mod auth;
20
21/// M4 mock 引擎(feature "mock";axum/actix feature 隐含启用)。
22#[cfg(feature = "mock")]
23pub mod mock;
24
25/// axum 适配器(feature "axum")。
26#[cfg(feature = "axum")]
27pub mod axum;
28
29/// actix-web 适配器(feature "actix")。
30#[cfg(feature = "actix")]
31pub mod actix;
32
33/// 共享文档 UI(axum/actix 适配器 include_str! 自本 crate,发布打包安全)。
34/// ui.html 为标记与样式、ui.js 为核心脚本、ui.debug.js 为在线调试面板,
35/// 编译期拼接为完整 HTML(同一 <script>,函数声明提升保证跨文件可见)。
36pub const UI_HTML: &str = concat!(
37    include_str!("ui.html"),
38    include_str!("ui.js"),
39    include_str!("ui.debug.js")
40);
41
42use serde::Serialize;
43
44/// Collects every `#[apidoc::*]` annotation from all linked crates.
45#[distributed_slice]
46pub static DOC_FRAGMENTS: [DocFragmentEntry];
47
48/// One annotation: the endpoint id plus the annotated piece of documentation.
49/// `seq` is assigned by the macro at expansion time (source order) and is used
50/// by `DocRegistry::collect` to restore declaration order, since linkme's
51/// linker-section iteration order is not source order.
52pub struct DocFragmentEntry {
53    pub id: &'static str,
54    pub seq: u32,
55    pub frag: DocFragment,
56}
57
58/// A single annotation payload.
59pub enum DocFragment {
60    Title(&'static str),
61    Desc(&'static str),
62    Method(&'static str),
63    Url(&'static str),
64    Param(DocParam),
65    Query(DocParam),
66    Returned(DocParam),
67    // M3: single-value fragments (later mounts win), list fragments (append),
68    // flag fragments (OR), and the ref reference.
69    Tag(&'static str),
70    Group(&'static str),
71    Author(&'static str),
72    Header(DocHeader),
73    RouteParam(DocParam),
74    ResponseStatus(&'static str),
75    Success(DocExample),
76    Error(DocExample),
77    NotDebug,
78    Md(&'static str),
79    Sort(i32),
80    Ref(&'static str),
81    // M6b: 挂到指定应用/版本 key 下(key 须在 ApidocConfig.apps 中配置,否则落默认应用)。
82    App(&'static str),
83}
84
85/// A documented example response body (shared by success / error).
86#[derive(Clone, Serialize)]
87pub struct DocExample {
88    /// HTTP status code, kept as a string to align with the PHP version.
89    pub code: &'static str,
90    /// Raw response body; stored verbatim, not validated as JSON.
91    pub example: &'static str,
92}
93
94/// A documented parameter (body param, query string field or return field).
95#[derive(Clone, Serialize)]
96pub struct DocParam {
97    pub name: &'static str,
98    #[serde(rename = "type")]
99    pub ty: &'static str,
100    pub required: bool,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub default: Option<&'static str>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub desc: Option<&'static str>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub mock: Option<&'static str>,
107    #[serde(skip_serializing_if = "slice_is_empty")]
108    pub children: &'static [DocParam],
109}
110
111fn slice_is_empty<T>(s: &[T]) -> bool {
112    s.is_empty()
113}
114
115/// One documented HTTP endpoint, built by merging fragments with the same id.
116#[derive(Clone, Serialize)]
117pub struct DocEndpoint {
118    pub title: String,
119    pub desc: String,
120    pub url: String,
121    pub method: String,
122    #[serde(skip_serializing_if = "Vec::is_empty")]
123    pub headers: Vec<DocHeader>,
124    pub params: Vec<DocParam>,
125    pub querys: Vec<DocParam>,
126    pub returned: Vec<DocParam>,
127    // M3 fields: all optional, all omitted from JSON when at their default
128    // value, so api.json output is unchanged for endpoints that use none of
129    // the new annotations.
130    #[serde(skip_serializing_if = "String::is_empty")]
131    pub group: String,
132    #[serde(skip_serializing_if = "Vec::is_empty")]
133    pub tags: Vec<String>,
134    #[serde(skip_serializing_if = "String::is_empty")]
135    pub author: String,
136    #[serde(skip_serializing_if = "Vec::is_empty")]
137    pub route_params: Vec<DocParam>,
138    #[serde(skip_serializing_if = "Vec::is_empty")]
139    pub response_status: Vec<String>,
140    #[serde(skip_serializing_if = "Vec::is_empty")]
141    pub success: Vec<DocExample>,
142    #[serde(skip_serializing_if = "Vec::is_empty")]
143    pub error: Vec<DocExample>,
144    #[serde(skip_serializing_if = "std::ops::Not::not")]
145    pub not_debug: bool,
146    #[serde(skip_serializing_if = "String::is_empty")]
147    pub md: String,
148    #[serde(skip_serializing_if = "is_zero")]
149    pub sort: i32,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub r#ref: Option<String>,
152    // 多应用归属(#[apidoc::app] 注解),仅 collect 用,不进任何序列化输出。
153    #[serde(skip)]
154    pub app_key: String,
155}
156
157fn is_zero(n: &i32) -> bool {
158    *n == 0
159}
160
161impl Default for DocEndpoint {
162    fn default() -> Self {
163        DocEndpoint {
164            title: String::new(),
165            desc: String::new(),
166            url: String::new(),
167            method: "GET".to_string(),
168            headers: Vec::new(),
169            params: Vec::new(),
170            querys: Vec::new(),
171            returned: Vec::new(),
172            group: String::new(),
173            tags: Vec::new(),
174            author: String::new(),
175            route_params: Vec::new(),
176            response_status: Vec::new(),
177            success: Vec::new(),
178            error: Vec::new(),
179            not_debug: false,
180            md: String::new(),
181            sort: 0,
182            r#ref: None,
183            app_key: String::new(),
184        }
185    }
186}
187
188/// Request header documentation, e.g. `#[apidoc::header(name = "X-Token")]`.
189#[derive(Clone, Serialize)]
190pub struct DocHeader {
191    pub name: &'static str,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub desc: Option<&'static str>,
194}
195
196/// 应用/版本配置树:key 为注解引用的唯一标识,title 为展示名,items 递归嵌套
197/// 版本,password 为该应用的独立访问密码(优先级高于全局密码,永不序列化)。
198#[derive(Clone)]
199pub struct AppConfig {
200    pub key: String,
201    pub title: String,
202    pub items: Vec<AppConfig>,
203    pub password: Option<String>,
204}
205
206/// 按配置树在 ApidocConfig.apps 中递归查找应用配置。
207pub fn find_app<'a>(apps: &'a [AppConfig], key: &str) -> Option<&'a AppConfig> {
208    for app in apps {
209        if app.key == key {
210            return Some(app);
211        }
212        if let Some(found) = find_app(&app.items, key) {
213            return Some(found);
214        }
215    }
216    None
217}
218
219/// Project-level configuration, combined with endpoints into the final output.
220#[derive(Serialize)]
221pub struct ApidocConfig {
222    pub title: String,
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub description: Option<String>,
225    // 密码鉴权。None 时 api.json 输出与 M5 字节级一致(红线)。
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub auth: Option<auth::AuthConfig>,
228    // 应用/版本配置树。仅服务端使用(校验注解 key、应用密码),不进任何输出。
229    #[serde(skip)]
230    pub apps: Vec<AppConfig>,
231}
232
233/// 一个应用/版本节点:注解挂载的 endpoints + 递归子版本。
234#[derive(Serialize)]
235pub struct AppDoc {
236    pub key: String,
237    pub title: String,
238    #[serde(skip_serializing_if = "Vec::is_empty")]
239    pub items: Vec<AppDoc>,
240    #[serde(skip_serializing_if = "Vec::is_empty")]
241    pub endpoints: Vec<DocEndpoint>,
242}
243
244/// Final aggregated document (the shape of api.json).
245#[derive(Serialize)]
246pub struct ApiDoc {
247    pub config: ApidocConfig,
248    pub endpoints: Vec<DocEndpoint>,
249    // 应用/版本树。无 app 注解或未配置 apps 时省略,输出与 M5 字节级一致(红线)。
250    #[serde(skip_serializing_if = "Vec::is_empty")]
251    pub apps: Vec<AppDoc>,
252}
253
254/// Collects and merges all registered fragments into per-endpoint documents.
255pub struct DocRegistry;
256
257impl DocRegistry {
258    /// M1-M5 行为不变:仅返回合并后的端点列表。
259    pub fn collect() -> Vec<DocEndpoint> {
260        Self::collect_inner()
261    }
262
263    /// 构建完整文档(端点 + 应用/版本树)。apps 按 ApidocConfig.apps 配置树
264    /// 挂载注解端点;app 注解引用未配置的 key 时 eprintln 警告并落默认应用(根层)。
265    pub fn collect_doc(config: ApidocConfig) -> ApiDoc {
266        let endpoints = Self::collect_inner();
267        let apps = build_apps(&config.apps, &endpoints);
268        ApiDoc { config, endpoints, apps }
269    }
270
271    fn collect_inner() -> Vec<DocEndpoint> {
272        // Sort by seq first: linkme's iteration order is linker-defined, not
273        // source order. Cross-crate ordering stays linker-arbitrary; seq ties
274        // between crates keep linkme's stable order. ponytail: acceptable for
275        // M1, revisit if multi-crate endpoint ordering ever matters.
276        let mut entries: Vec<&DocFragmentEntry> = DOC_FRAGMENTS.iter().collect();
277        entries.sort_by_key(|e| e.seq);
278        let mut ids: Vec<&'static str> = Vec::new();
279        let mut endpoints: Vec<DocEndpoint> = Vec::new();
280        for entry in entries {
281            // ponytail: O(n²) linear id lookup; fine for doc-sized inputs, swap
282            // to a HashMap<&str, usize> if thousands of endpoints ever appear.
283            let idx = match ids.iter().position(|id| *id == entry.id) {
284                Some(i) => i,
285                None => {
286                    ids.push(entry.id);
287                    endpoints.push(DocEndpoint::default());
288                    endpoints.len() - 1
289                }
290            };
291            let ep = &mut endpoints[idx];
292            match &entry.frag {
293                DocFragment::Title(t) => ep.title = t.to_string(),
294                DocFragment::Desc(d) => ep.desc = d.to_string(),
295                DocFragment::Method(m) => ep.method = m.to_string(),
296                DocFragment::Url(u) => ep.url = u.to_string(),
297                DocFragment::Param(p) => ep.params.push(p.clone()),
298                DocFragment::Query(q) => ep.querys.push(q.clone()),
299                DocFragment::Returned(r) => ep.returned.push(r.clone()),
300                // M3: single-value fields overwrite (later mount wins), lists
301                // append, response_status dedups, not_debug ORs, ref overwrites.
302                DocFragment::Tag(t) => ep.tags.push(t.to_string()),
303                DocFragment::Group(g) => ep.group = g.to_string(),
304                DocFragment::Author(a) => ep.author = a.to_string(),
305                DocFragment::Header(h) => ep.headers.push(h.clone()),
306                DocFragment::RouteParam(p) => ep.route_params.push(p.clone()),
307                DocFragment::ResponseStatus(s) => {
308                    if !ep.response_status.iter().any(|x| x == s) {
309                        ep.response_status.push(s.to_string());
310                    }
311                }
312                DocFragment::Success(e) => ep.success.push(e.clone()),
313                DocFragment::Error(e) => ep.error.push(e.clone()),
314                DocFragment::NotDebug => ep.not_debug = true,
315                DocFragment::Md(m) => ep.md = m.to_string(),
316                DocFragment::Sort(n) => ep.sort = *n,
317                DocFragment::Ref(r) => ep.r#ref = Some(r.to_string()),
318                DocFragment::App(a) => ep.app_key = a.to_string(),
319            }
320        }
321        // Second pass: resolve ref chains (copy the target's `returned` into
322        // the referencing endpoint). Runs after every endpoint exists so the
323        // target may be declared anywhere, and recursively so chains A→B→C
324        // resolve; cycles are cut by the visited set (warned, not copied).
325        for i in 0..endpoints.len() {
326            if endpoints[i].r#ref.is_some() {
327                resolve_ref(i, &mut Vec::new(), &ids, &mut endpoints);
328            }
329        }
330        endpoints
331    }
332}
333
334/// 按配置树构建 AppDoc 树;未配置的注解 key 警告并留在根层(默认应用)。
335fn build_apps(config_apps: &[AppConfig], endpoints: &[DocEndpoint]) -> Vec<AppDoc> {
336    for ep in endpoints.iter().filter(|e| !e.app_key.is_empty()) {
337        if find_app(config_apps, &ep.app_key).is_none() {
338            eprintln!("apidoc: app `{}` not configured in ApidocConfig.apps, endpoints fall back to the default app", ep.app_key);
339        }
340    }
341    config_apps.iter().map(|c| build_app_doc(c, endpoints)).collect()
342}
343
344fn build_app_doc(cfg: &AppConfig, endpoints: &[DocEndpoint]) -> AppDoc {
345    let eps: Vec<DocEndpoint> = endpoints.iter().filter(|e| e.app_key == cfg.key).cloned().collect();
346    AppDoc {
347        key: cfg.key.clone(),
348        title: cfg.title.clone(),
349        items: cfg.items.iter().map(|c| build_app_doc(c, endpoints)).collect(),
350        endpoints: eps,
351    }
352}
353
354/// Copies the ref target's `returned` into `endpoints[idx].returned`.
355/// Returns false (no copy) when the target is missing or a cycle is hit.
356fn resolve_ref(
357    idx: usize,
358    visited: &mut Vec<usize>,
359    ids: &[&'static str],
360    endpoints: &mut [DocEndpoint],
361) -> bool {
362    if visited.contains(&idx) {
363        eprintln!("apidoc: ref cycle at `{}`, skipping", ids[idx]);
364        return false;
365    }
366    visited.push(idx);
367    let Some(target) = endpoints[idx].r#ref.as_deref() else {
368        return true;
369    };
370    let Some(j) = find_ref_target(target, ids) else {
371        eprintln!("apidoc: ref target `{target}` not found for `{}`", ids[idx]);
372        return false;
373    };
374    if !resolve_ref(j, visited, ids, endpoints) {
375        return false;
376    }
377    endpoints[idx].returned = endpoints[j].returned.clone();
378    true
379}
380
381/// Locates a ref target: exact id match first, then `::fn_name` suffix match
382/// (the PHP-style global reference by function name).
383fn find_ref_target(target: &str, ids: &[&'static str]) -> Option<usize> {
384    if let Some(j) = ids.iter().position(|id| *id == target) {
385        return Some(j);
386    }
387    let suffix = format!("::{target}");
388    let mut hits = ids.iter().enumerate().filter(|(_, id)| id.ends_with(&suffix));
389    let (j, _) = hits.next()?;
390    if hits.next().is_some() {
391        eprintln!("apidoc: ref `{target}` matches multiple endpoints, using `{}`", ids[j]);
392    }
393    Some(j)
394}