Skip to main content

poolster_core/
api_reference.rs

1//! Optional target-neutral API reference consumer. Never includes example/default values.
2use crate::{
3    Api, GeneratedFile, SchemaKind, SchemaValue,
4    engine::{Contract, Handle, Language, Meta, Plugin, PluginContext, Provision},
5};
6use anyhow::Result;
7use std::{fmt::Write, marker::PhantomData};
8
9#[derive(Clone, Debug)]
10pub struct ApiReferenceDocument {
11    pub path: String,
12    pub contents: String,
13}
14impl Contract for ApiReferenceDocument {
15    const NAME: &'static str = "poolster.api-reference.v1";
16}
17pub struct ApiReference<L: Language> {
18    meta: Meta,
19    output: String,
20    language: PhantomData<L>,
21}
22pub fn api_reference<L: Language>() -> ApiReference<L> {
23    ApiReference {
24        meta: Meta::new(),
25        output: "API_REFERENCE.md".into(),
26        language: PhantomData,
27    }
28}
29impl<L: Language> ApiReference<L> {
30    pub fn handle(&self) -> Handle<ApiReferenceDocument> {
31        self.meta.handle()
32    }
33    pub fn output(mut self, path: impl Into<String>) -> Self {
34        self.output = path.into();
35        self
36    }
37}
38impl<L: Language> Plugin<L> for ApiReference<L> {
39    fn kind(&self) -> &'static str {
40        "api-reference"
41    }
42    fn meta(&self) -> &Meta {
43        &self.meta
44    }
45    fn provides(&self) -> Vec<Provision> {
46        vec![Provision::of::<ApiReferenceDocument>()]
47    }
48    fn generate(&self, cx: &mut PluginContext<'_, L>) -> Result<()> {
49        let contents = render(cx.api);
50        cx.files
51            .emit(GeneratedFile::new(&self.output, &contents)?)?;
52        cx.publish(ApiReferenceDocument {
53            path: self.output.clone(),
54            contents,
55        })
56    }
57}
58fn escaped(value: &str) -> String {
59    value
60        .chars()
61        .map(|c| match c {
62            '&' => "&amp;".into(),
63            '<' => "&lt;".into(),
64            '>' => "&gt;".into(),
65            '|' | '`' | '*' | '_' | '[' | ']' | '(' | ')' | '!' | '#' | ':' | '\\' => {
66                format!("&#{};", c as u32)
67            }
68            c if c.is_control() => " ".into(),
69            _ => c.to_string(),
70        })
71        .collect()
72}
73fn shape(schema: Option<&SchemaValue>, depth: usize) -> String {
74    let Some(schema) = schema else {
75        return "No schema declared".into();
76    };
77    if depth > 12 {
78        return "Nested schema (depth limit)".into();
79    }
80    let mut result = match &schema.kind {
81        SchemaKind::Any => "any".into(),
82        SchemaKind::Null => "null".into(),
83        SchemaKind::Boolean => "boolean".into(),
84        SchemaKind::Integer => "integer".into(),
85        SchemaKind::Number => "number".into(),
86        SchemaKind::String => "string".into(),
87        SchemaKind::Reference { reference } => {
88            if reference.starts_with('#') {
89                format!("Reference {}", escaped(reference))
90            } else {
91                "External reference (URI omitted)".into()
92            }
93        }
94        SchemaKind::Array { items } => format!("array of {}", shape(Some(items), depth + 1)),
95        SchemaKind::Object { fields, .. } => format!("object ({} fields)", fields.len()),
96        SchemaKind::OneOf { variants } => format!("oneOf ({} variants)", variants.len()),
97        SchemaKind::AnyOf { variants } => format!("anyOf ({} variants)", variants.len()),
98        SchemaKind::AllOf { variants } => format!("allOf ({} variants)", variants.len()),
99        SchemaKind::Not { .. } => "not schema".into(),
100    };
101    if schema.nullable || schema.nullish {
102        result.push_str("; nullable")
103    }
104    if schema.read_only {
105        result.push_str("; readOnly")
106    }
107    if schema.write_only {
108        result.push_str("; writeOnly")
109    }
110    result
111}
112pub fn render(api: &Api) -> String {
113    let mut out = format!(
114        "# {} API reference\n\nVersion: {}\n\nThis reference describes the normalized API contract, not generated client method names. Examples, defaults, source descriptions and credential values are omitted. External reference URIs are omitted.\n\n",
115        escaped(&api.name),
116        escaped(&api.version)
117    );
118    let mut operations = api.operations.iter().collect::<Vec<_>>();
119    operations.sort_by(|a, b| (&a.id, &a.path).cmp(&(&b.id, &b.path)));
120    for op in operations {
121        let _ = writeln!(
122            out,
123            "## {}\n\n{} {}\n",
124            escaped(&op.id),
125            op.method.as_str(),
126            escaped(&op.path)
127        );
128        out.push_str("### Parameters\n\n| Name | Location | Required | Schema |\n| --- | --- | --- | --- |\n");
129        for p in &op.parameters {
130            let _ = writeln!(
131                out,
132                "| {} | {} | {} | {} |",
133                escaped(&p.name),
134                escaped(&p.location),
135                p.required,
136                shape(p.schema.as_ref(), 0)
137            );
138        }
139        if op.parameters.is_empty() {
140            out.push_str("| — | — | — | None declared |\n")
141        }
142        if let Some(policy) = crate::idempotency::resolved(op) {
143            let _ = writeln!(
144                out,
145                "\n### Idempotency\n\nKey header: `{}`. {} Caller-supplied keys take precedence and each automatic retry reuses the same key. To retry a logical operation across SDK calls or process restarts, supply and persist your own key. The API server must implement deduplication.\n",
146                escaped(&policy.header),
147                if policy.auto_generate {
148                    "An omitted key receives a fresh UUID per SDK call."
149                } else {
150                    "Keys are caller-supplied; automatic generation is disabled."
151                }
152            );
153        }
154        if let Some(body) = &op.request_body {
155            let _ = writeln!(
156                out,
157                "\n### Request body\n\nRequired: {}\n\n| Media type | Schema |\n| --- | --- |",
158                body.required
159            );
160            for media in &body.media_types {
161                let _ = writeln!(
162                    out,
163                    "| {} | {} |",
164                    escaped(&media.content_type),
165                    shape(media.schema.as_ref(), 0)
166                );
167            }
168        }
169        out.push_str("\n### Responses\n\n| Status | Media type | Schema |\n| --- | --- | --- |\n");
170        for response in &op.responses {
171            if response.media_types.is_empty() {
172                let _ = writeln!(
173                    out,
174                    "| {} | — | No representation declared |",
175                    escaped(&response.status)
176                );
177            }
178            for media in &response.media_types {
179                let _ = writeln!(
180                    out,
181                    "| {} | {} | {} |",
182                    escaped(&response.status),
183                    escaped(&media.content_type),
184                    shape(media.schema.as_ref(), 0)
185                );
186            }
187        }
188        if op.responses.is_empty() {
189            out.push_str("| — | — | None declared |\n")
190        }
191        out.push('\n');
192    }
193    out.push_str("## Component schemas\n\n| Name | Shape |\n| --- | --- |\n");
194    let mut schemas = api.schemas.iter().collect::<Vec<_>>();
195    schemas.sort_by(|a, b| a.name.cmp(&b.name));
196    for schema in &schemas {
197        let _ = writeln!(
198            out,
199            "| {} | {} |",
200            escaped(&schema.name),
201            shape(Some(&schema.value), 0)
202        );
203    }
204    for schema in schemas {
205        if let SchemaKind::Object { fields, .. } = &schema.value.kind {
206            let _ = writeln!(
207                out,
208                "\n### {} fields\n\n| Name | Required | Shape |\n| --- | --- | --- |",
209                escaped(&schema.name)
210            );
211            for field in fields {
212                let _ = writeln!(
213                    out,
214                    "| {} | {} | {} |",
215                    escaped(&field.name),
216                    field.required,
217                    shape(Some(&field.value), 0)
218                );
219            }
220        }
221    }
222    out
223}
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::{
228        HttpMethod, Operation, OperationMediaType, OperationResponse, Schema, engine::Packages,
229    };
230    struct Test;
231    impl Language for Test {
232        const NAME: &'static str = "test";
233        type Settings = ();
234        type Workspace = ();
235    }
236    #[test]
237    fn reference_escapes_untrusted_text_and_omits_source_secrets() {
238        let mut schema = SchemaValue::new(SchemaKind::String);
239        schema.default = Some(serde_json::json!("secret-default"));
240        schema.enum_values = vec![serde_json::json!("secret-enum")];
241        schema.description = Some("secret-description".into());
242        let mut api = Api {
243            name: "<script>|API".into(),
244            schemas: vec![Schema::new("name|`", schema)],
245            ..Default::default()
246        };
247        api.operations.push(Operation {
248            id: "read[evil](https://evil)".into(),
249            method: HttpMethod::Get,
250            path: "/things/{id}".into(),
251            responses: vec![OperationResponse {
252                status: "200".into(),
253                description: Some("secret-response".into()),
254                media_types: vec![OperationMediaType {
255                    content_type: "application/json".into(),
256                    schema: Some(SchemaValue::reference(
257                        "https://example.test/schema?secret-token",
258                    )),
259                }],
260            }],
261            ..Default::default()
262        });
263        let text = render(&api);
264        assert!(!text.contains("<script>"));
265        assert!(!text.contains("secret"));
266        assert!(text.contains("application/json"));
267        assert!(text.contains("External reference (URI omitted)"));
268        assert!(text.contains("&#124;"));
269    }
270    #[test]
271    fn reference_documents_resolved_package_policy() {
272        let api = Api {
273            operations: vec![crate::Operation {
274                id: "createOrder".into(),
275                method: crate::HttpMethod::Post,
276                path: "/orders".into(),
277                ..Default::default()
278            }],
279            ..Default::default()
280        };
281        let config = crate::idempotency::IdempotencyConfig {
282            operations: std::collections::BTreeMap::from([(
283                "createOrder".into(),
284                crate::idempotency::IdempotencyRule {
285                    header: "X-Once".into(),
286                    auto_generate: true,
287                    ..Default::default()
288                },
289            )]),
290            ..Default::default()
291        };
292        let tree = Packages::new()
293            .package(
294                crate::engine::Package::<Test>::new("docs")
295                    .idempotency(config)
296                    .with(api_reference()),
297            )
298            .generate(&api, None)
299            .unwrap();
300        let document = tree.get("docs/API_REFERENCE.md").unwrap();
301        assert!(document.contains("Key header: `X-Once`"));
302        assert!(document.contains("fresh UUID per SDK call"));
303        assert!(document.contains("process restarts"));
304        assert!(document.contains("server must implement deduplication"));
305        assert!(api.operations[0].parameters.is_empty());
306    }
307
308    #[test]
309    fn plugin_emits_owned_custom_output_and_rejects_escaping_paths() {
310        let package = crate::engine::Package::<Test>::new("docs")
311            .with(api_reference().output("reference/API.md"));
312        let tree = Packages::new()
313            .package(package)
314            .generate(&Api::default(), None)
315            .unwrap();
316        assert!(tree.get("docs/reference/API.md").is_some());
317        assert!(
318            Packages::new()
319                .package(
320                    crate::engine::Package::<Test>::new("docs")
321                        .with(api_reference().output("../escape"))
322                )
323                .generate(&Api::default(), None)
324                .is_err()
325        );
326    }
327}